Bug 15986: Add a script for sending hold waiting reminder notices
[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
9 # under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # Koha is distributed in the hope that it will be useful, but
14 # WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with Koha; if not, see <http://www.gnu.org/licenses>.
20
21 use Modern::Perl;
22
23 use Carp;
24 use List::MoreUtils qw( any uniq );
25 use JSON qw( to_json );
26 use Unicode::Normalize;
27
28 use C4::Context;
29 use C4::Log;
30 use Koha::Account;
31 use Koha::ArticleRequests;
32 use C4::Letters qw( GetPreparedLetter EnqueueLetter );
33 use Koha::AuthUtils;
34 use Koha::Checkouts;
35 use Koha::Club::Enrollments;
36 use Koha::Database;
37 use Koha::DateUtils;
38 use Koha::Exceptions::Password;
39 use Koha::Holds;
40 use Koha::Old::Checkouts;
41 use Koha::Patron::Attributes;
42 use Koha::Patron::Categories;
43 use Koha::Patron::Debarments;
44 use Koha::Patron::HouseboundProfile;
45 use Koha::Patron::HouseboundRole;
46 use Koha::Patron::Images;
47 use Koha::Patron::Modifications;
48 use Koha::Patron::Relationships;
49 use Koha::Patrons;
50 use Koha::Plugins;
51 use Koha::Subscription::Routinglists;
52 use Koha::Token;
53 use Koha::Virtualshelves;
54
55 use base qw(Koha::Object);
56
57 use constant ADMINISTRATIVE_LOCKOUT => -1;
58
59 our $RESULTSET_PATRON_ID_MAPPING = {
60     Accountline          => 'borrowernumber',
61     Aqbasketuser         => 'borrowernumber',
62     Aqbudget             => 'budget_owner_id',
63     Aqbudgetborrower     => 'borrowernumber',
64     ArticleRequest       => 'borrowernumber',
65     BorrowerAttribute    => 'borrowernumber',
66     BorrowerDebarment    => 'borrowernumber',
67     BorrowerFile         => 'borrowernumber',
68     BorrowerModification => 'borrowernumber',
69     ClubEnrollment       => 'borrowernumber',
70     Issue                => 'borrowernumber',
71     ItemsLastBorrower    => 'borrowernumber',
72     Linktracker          => 'borrowernumber',
73     Message              => 'borrowernumber',
74     MessageQueue         => 'borrowernumber',
75     OldIssue             => 'borrowernumber',
76     OldReserve           => 'borrowernumber',
77     Rating               => 'borrowernumber',
78     Reserve              => 'borrowernumber',
79     Review               => 'borrowernumber',
80     SearchHistory        => 'userid',
81     Statistic            => 'borrowernumber',
82     Suggestion           => 'suggestedby',
83     TagAll               => 'borrowernumber',
84     Virtualshelfcontent  => 'borrowernumber',
85     Virtualshelfshare    => 'borrowernumber',
86     Virtualshelve        => 'owner',
87 };
88
89 =head1 NAME
90
91 Koha::Patron - Koha Patron Object class
92
93 =head1 API
94
95 =head2 Class Methods
96
97 =head3 new
98
99 =cut
100
101 sub new {
102     my ( $class, $params ) = @_;
103
104     return $class->SUPER::new($params);
105 }
106
107 =head3 fixup_cardnumber
108
109 Autogenerate next cardnumber from highest value found in database
110
111 =cut
112
113 sub fixup_cardnumber {
114     my ( $self ) = @_;
115     my $max = Koha::Patrons->search({
116         cardnumber => {-regexp => '^-?[0-9]+$'}
117     }, {
118         select => \'CAST(cardnumber AS SIGNED)',
119         as => ['cast_cardnumber']
120     })->_resultset->get_column('cast_cardnumber')->max;
121     $self->cardnumber(($max || 0) +1);
122 }
123
124 =head3 trim_whitespace
125
126 trim whitespace from data which has some non-whitespace in it.
127 Could be moved to Koha::Object if need to be reused
128
129 =cut
130
131 sub trim_whitespaces {
132     my( $self ) = @_;
133
134     my $schema  = Koha::Database->new->schema;
135     my @columns = $schema->source($self->_type)->columns;
136
137     for my $column( @columns ) {
138         my $value = $self->$column;
139         if ( defined $value ) {
140             $value =~ s/^\s*|\s*$//g;
141             $self->$column($value);
142         }
143     }
144     return $self;
145 }
146
147 =head3 plain_text_password
148
149 $patron->plain_text_password( $password );
150
151 stores a copy of the unencrypted password in the object
152 for use in code before encrypting for db
153
154 =cut
155
156 sub plain_text_password {
157     my ( $self, $password ) = @_;
158     if ( $password ) {
159         $self->{_plain_text_password} = $password;
160         return $self;
161     }
162     return $self->{_plain_text_password}
163         if $self->{_plain_text_password};
164
165     return;
166 }
167
168 =head3 store
169
170 Patron specific store method to cleanup record
171 and do other necessary things before saving
172 to db
173
174 =cut
175
176 sub store {
177     my ($self) = @_;
178
179     $self->_result->result_source->schema->txn_do(
180         sub {
181             if (
182                 C4::Context->preference("autoMemberNum")
183                 and ( not defined $self->cardnumber
184                     or $self->cardnumber eq '' )
185               )
186             {
187                 # Warning: The caller is responsible for locking the members table in write
188                 # mode, to avoid database corruption.
189                 # We are in a transaction but the table is not locked
190                 $self->fixup_cardnumber;
191             }
192
193             unless( $self->category->in_storage ) {
194                 Koha::Exceptions::Object::FKConstraint->throw(
195                     broken_fk => 'categorycode',
196                     value     => $self->categorycode,
197                 );
198             }
199
200             $self->trim_whitespaces;
201
202             # Set surname to uppercase if uppercasesurname is true
203             $self->surname( uc($self->surname) )
204                 if C4::Context->preference("uppercasesurnames");
205
206             $self->relationship(undef) # We do not want to store an empty string in this field
207               if defined $self->relationship
208                      and $self->relationship eq "";
209
210             unless ( $self->in_storage ) {    #AddMember
211
212                 # Generate a valid userid/login if needed
213                 $self->generate_userid
214                   if not $self->userid or not $self->has_valid_userid;
215
216                 # Add expiration date if it isn't already there
217                 unless ( $self->dateexpiry ) {
218                     $self->dateexpiry( $self->category->get_expiry_date );
219                 }
220
221                 # Add enrollment date if it isn't already there
222                 unless ( $self->dateenrolled ) {
223                     $self->dateenrolled(dt_from_string);
224                 }
225
226                 # Set the privacy depending on the patron's category
227                 my $default_privacy = $self->category->default_privacy || q{};
228                 $default_privacy =
229                     $default_privacy eq 'default' ? 1
230                   : $default_privacy eq 'never'   ? 2
231                   : $default_privacy eq 'forever' ? 0
232                   :                                                   undef;
233                 $self->privacy($default_privacy);
234
235                 # Call any check_password plugins if password is passed
236                 if ( C4::Context->config("enable_plugins") && $self->password ) {
237                     my @plugins = Koha::Plugins->new()->GetPlugins({
238                         method => 'check_password',
239                     });
240                     foreach my $plugin ( @plugins ) {
241                         # This plugin hook will also be used by a plugin for the Norwegian national
242                         # patron database. This is why we need to pass both the password and the
243                         # borrowernumber to the plugin.
244                         my $ret = $plugin->check_password(
245                             {
246                                 password       => $self->password,
247                                 borrowernumber => $self->borrowernumber
248                             }
249                         );
250                         if ( $ret->{'error'} == 1 ) {
251                             Koha::Exceptions::Password::Plugin->throw();
252                         }
253                     }
254                 }
255
256                 # Make a copy of the plain text password for later use
257                 $self->plain_text_password( $self->password );
258
259                 # Create a disabled account if no password provided
260                 $self->password( $self->password
261                     ? Koha::AuthUtils::hash_password( $self->password )
262                     : '!' );
263
264                 $self->borrowernumber(undef);
265
266                 $self = $self->SUPER::store;
267
268                 $self->add_enrolment_fee_if_needed(0);
269
270                 logaction( "MEMBERS", "CREATE", $self->borrowernumber, "" )
271                   if C4::Context->preference("BorrowersLog");
272             }
273             else {    #ModMember
274
275                 my $self_from_storage = $self->get_from_storage;
276                 # FIXME We should not deal with that here, callers have to do this job
277                 # Moved from ModMember to prevent regressions
278                 unless ( $self->userid ) {
279                     my $stored_userid = $self_from_storage->userid;
280                     $self->userid($stored_userid);
281                 }
282
283                 # Password must be updated using $self->set_password
284                 $self->password($self_from_storage->password);
285
286                 if ( $self->category->categorycode ne
287                     $self_from_storage->category->categorycode )
288                 {
289                     # Add enrolement fee on category change if required
290                     $self->add_enrolment_fee_if_needed(1)
291                       if C4::Context->preference('FeeOnChangePatronCategory');
292
293                     # Clean up guarantors on category change if required
294                     $self->guarantor_relationships->delete
295                       if ( $self->category->category_type ne 'C'
296                         && $self->category->category_type ne 'P' );
297
298                 }
299
300                 # Actionlogs
301                 if ( C4::Context->preference("BorrowersLog") ) {
302                     my $info;
303                     my $from_storage = $self_from_storage->unblessed;
304                     my $from_object  = $self->unblessed;
305                     my @skip_fields  = (qw/lastseen updated_on/);
306                     for my $key ( keys %{$from_storage} ) {
307                         next if any { /$key/ } @skip_fields;
308                         if (
309                             (
310                                   !defined( $from_storage->{$key} )
311                                 && defined( $from_object->{$key} )
312                             )
313                             || ( defined( $from_storage->{$key} )
314                                 && !defined( $from_object->{$key} ) )
315                             || (
316                                    defined( $from_storage->{$key} )
317                                 && defined( $from_object->{$key} )
318                                 && ( $from_storage->{$key} ne
319                                     $from_object->{$key} )
320                             )
321                           )
322                         {
323                             $info->{$key} = {
324                                 before => $from_storage->{$key},
325                                 after  => $from_object->{$key}
326                             };
327                         }
328                     }
329
330                     if ( defined($info) ) {
331                         logaction(
332                             "MEMBERS",
333                             "MODIFY",
334                             $self->borrowernumber,
335                             to_json(
336                                 $info,
337                                 { utf8 => 1, pretty => 1, canonical => 1 }
338                             )
339                         );
340                     }
341                 }
342
343                 # Final store
344                 $self = $self->SUPER::store;
345             }
346         }
347     );
348     return $self;
349 }
350
351 =head3 delete
352
353 $patron->delete
354
355 Delete patron's holds, lists and finally the patron.
356
357 Lists owned by the borrower are deleted, but entries from the borrower to
358 other lists are kept.
359
360 =cut
361
362 sub delete {
363     my ($self) = @_;
364
365     my $anonymous_patron = C4::Context->preference("AnonymousPatron");
366     Koha::Exceptions::Patron::FailedDeleteAnonymousPatron->throw() if $anonymous_patron && $self->id eq $anonymous_patron;
367
368     $self->_result->result_source->schema->txn_do(
369         sub {
370             # Cancel Patron's holds
371             my $holds = $self->holds;
372             while( my $hold = $holds->next ){
373                 $hold->cancel;
374             }
375
376             # Delete all lists and all shares of this borrower
377             # Consistent with the approach Koha uses on deleting individual lists
378             # Note that entries in virtualshelfcontents added by this borrower to
379             # lists of others will be handled by a table constraint: the borrower
380             # is set to NULL in those entries.
381             # NOTE:
382             # We could handle the above deletes via a constraint too.
383             # But a new BZ report 11889 has been opened to discuss another approach.
384             # Instead of deleting we could also disown lists (based on a pref).
385             # In that way we could save shared and public lists.
386             # The current table constraints support that idea now.
387             # This pref should then govern the results of other routines/methods such as
388             # Koha::Virtualshelf->new->delete too.
389             # FIXME Could be $patron->get_lists
390             $_->delete for Koha::Virtualshelves->search( { owner => $self->borrowernumber } );
391
392             # We cannot have a FK on borrower_modifications.borrowernumber, the table is also used
393             # for patron selfreg
394             $_->delete for Koha::Patron::Modifications->search( { borrowernumber => $self->borrowernumber } );
395
396             $self->SUPER::delete;
397
398             logaction( "MEMBERS", "DELETE", $self->borrowernumber, "" ) if C4::Context->preference("BorrowersLog");
399         }
400     );
401     return $self;
402 }
403
404
405 =head3 category
406
407 my $patron_category = $patron->category
408
409 Return the patron category for this patron
410
411 =cut
412
413 sub category {
414     my ( $self ) = @_;
415     return Koha::Patron::Category->_new_from_dbic( $self->_result->categorycode );
416 }
417
418 =head3 image
419
420 =cut
421
422 sub image {
423     my ( $self ) = @_;
424
425     return Koha::Patron::Images->find( $self->borrowernumber );
426 }
427
428 =head3 library
429
430 Returns a Koha::Library object representing the patron's home library.
431
432 =cut
433
434 sub library {
435     my ( $self ) = @_;
436     return Koha::Library->_new_from_dbic($self->_result->branchcode);
437 }
438
439 =head3 sms_provider
440
441 Returns a Koha::SMS::Provider object representing the patron's SMS provider.
442
443 =cut
444
445 sub sms_provider {
446     my ( $self ) = @_;
447     my $sms_provider_rs = $self->_result->sms_provider;
448     return unless $sms_provider_rs;
449     return Koha::SMS::Provider->_new_from_dbic($sms_provider_rs);
450 }
451
452 =head3 guarantor_relationships
453
454 Returns Koha::Patron::Relationships object for this patron's guarantors
455
456 Returns the set of relationships for the patrons that are guarantors for this patron.
457
458 This is returned instead of a Koha::Patron object because the guarantor
459 may not exist as a patron in Koha. If this is true, the guarantors name
460 exists in the Koha::Patron::Relationship object and will have no guarantor_id.
461
462 =cut
463
464 sub guarantor_relationships {
465     my ($self) = @_;
466
467     return Koha::Patron::Relationships->search( { guarantee_id => $self->id } );
468 }
469
470 =head3 guarantee_relationships
471
472 Returns Koha::Patron::Relationships object for this patron's guarantors
473
474 Returns the set of relationships for the patrons that are guarantees for this patron.
475
476 The method returns Koha::Patron::Relationship objects for the sake
477 of consistency with the guantors method.
478 A guarantee by definition must exist as a patron in Koha.
479
480 =cut
481
482 sub guarantee_relationships {
483     my ($self) = @_;
484
485     return Koha::Patron::Relationships->search(
486         { guarantor_id => $self->id },
487         {
488             prefetch => 'guarantee',
489             order_by => { -asc => [ 'guarantee.surname', 'guarantee.firstname' ] },
490         }
491     );
492 }
493
494 =head3 relationships_debt
495
496 Returns the amount owed by the patron's guarantors *and* the other guarantees of those guarantors
497
498 =cut
499
500 sub relationships_debt {
501     my ($self, $params) = @_;
502
503     my $include_guarantors  = $params->{include_guarantors};
504     my $only_this_guarantor = $params->{only_this_guarantor};
505     my $include_this_patron = $params->{include_this_patron};
506
507     my @guarantors;
508     if ( $only_this_guarantor ) {
509         @guarantors = $self->guarantee_relationships->count ? ( $self ) : ();
510         Koha::Exceptions::BadParameter->throw( { parameter => 'only_this_guarantor' } ) unless @guarantors;
511     } elsif ( $self->guarantor_relationships->count ) {
512         # I am a guarantee, just get all my guarantors
513         @guarantors = $self->guarantor_relationships->guarantors;
514     } else {
515         # I am a guarantor, I need to get all the guarantors of all my guarantees
516         @guarantors = map { $_->guarantor_relationships->guarantors } $self->guarantee_relationships->guarantees;
517     }
518
519     my $non_issues_charges = 0;
520     my $seen = $include_this_patron ? {} : { $self->id => 1 }; # For tracking members already added to the total
521     foreach my $guarantor (@guarantors) {
522         $non_issues_charges += $guarantor->account->non_issues_charges if $include_guarantors && !$seen->{ $guarantor->id };
523
524         # We've added what the guarantor owes, not added in that guarantor's guarantees as well
525         my @guarantees = map { $_->guarantee } $guarantor->guarantee_relationships();
526         my $guarantees_non_issues_charges = 0;
527         foreach my $guarantee (@guarantees) {
528             next if $seen->{ $guarantee->id };
529             $guarantees_non_issues_charges += $guarantee->account->non_issues_charges;
530             # Mark this guarantee as seen so we don't double count a guarantee linked to multiple guarantors
531             $seen->{ $guarantee->id } = 1;
532         }
533
534         $non_issues_charges += $guarantees_non_issues_charges;
535         $seen->{ $guarantor->id } = 1;
536     }
537
538     return $non_issues_charges;
539 }
540
541 =head3 housebound_profile
542
543 Returns the HouseboundProfile associated with this patron.
544
545 =cut
546
547 sub housebound_profile {
548     my ( $self ) = @_;
549     my $profile = $self->_result->housebound_profile;
550     return Koha::Patron::HouseboundProfile->_new_from_dbic($profile)
551         if ( $profile );
552     return;
553 }
554
555 =head3 housebound_role
556
557 Returns the HouseboundRole associated with this patron.
558
559 =cut
560
561 sub housebound_role {
562     my ( $self ) = @_;
563
564     my $role = $self->_result->housebound_role;
565     return Koha::Patron::HouseboundRole->_new_from_dbic($role) if ( $role );
566     return;
567 }
568
569 =head3 siblings
570
571 Returns the siblings of this patron.
572
573 =cut
574
575 sub siblings {
576     my ($self) = @_;
577
578     my @guarantors = $self->guarantor_relationships()->guarantors();
579
580     return unless @guarantors;
581
582     my @siblings =
583       map { $_->guarantee_relationships()->guarantees() } @guarantors;
584
585     return unless @siblings;
586
587     my %seen;
588     @siblings =
589       grep { !$seen{ $_->id }++ && ( $_->id != $self->id ) } @siblings;
590
591     return wantarray ? @siblings : Koha::Patrons->search( { borrowernumber => { -in => [ map { $_->id } @siblings ] } } );
592 }
593
594 =head3 merge_with
595
596     my $patron = Koha::Patrons->find($id);
597     $patron->merge_with( \@patron_ids );
598
599     This subroutine merges a list of patrons into the patron record. This is accomplished by finding
600     all related patron ids for the patrons to be merged in other tables and changing the ids to be that
601     of the keeper patron.
602
603 =cut
604
605 sub merge_with {
606     my ( $self, $patron_ids ) = @_;
607
608     my $anonymous_patron = C4::Context->preference("AnonymousPatron");
609     return if $anonymous_patron && $self->id eq $anonymous_patron;
610
611     my @patron_ids = @{ $patron_ids };
612
613     # Ensure the keeper isn't in the list of patrons to merge
614     @patron_ids = grep { $_ ne $self->id } @patron_ids;
615
616     my $schema = Koha::Database->new()->schema();
617
618     my $results;
619
620     $self->_result->result_source->schema->txn_do( sub {
621         foreach my $patron_id (@patron_ids) {
622
623             next if $patron_id eq $anonymous_patron;
624
625             my $patron = Koha::Patrons->find( $patron_id );
626
627             next unless $patron;
628
629             # Unbless for safety, the patron will end up being deleted
630             $results->{merged}->{$patron_id}->{patron} = $patron->unblessed;
631
632             while (my ($r, $field) = each(%$RESULTSET_PATRON_ID_MAPPING)) {
633                 my $rs = $schema->resultset($r)->search({ $field => $patron_id });
634                 $results->{merged}->{ $patron_id }->{updated}->{$r} = $rs->count();
635                 $rs->update({ $field => $self->id });
636                 if ( $r eq 'BorrowerDebarment' ) {
637                     Koha::Patron::Debarments::UpdateBorrowerDebarmentFlags($self->id);
638                 }
639             }
640
641             $patron->move_to_deleted();
642             $patron->delete();
643         }
644     });
645
646     return $results;
647 }
648
649
650
651 =head3 wants_check_for_previous_checkout
652
653     $wants_check = $patron->wants_check_for_previous_checkout;
654
655 Return 1 if Koha needs to perform PrevIssue checking, else 0.
656
657 =cut
658
659 sub wants_check_for_previous_checkout {
660     my ( $self ) = @_;
661     my $syspref = C4::Context->preference("checkPrevCheckout");
662
663     # Simple cases
664     ## Hard syspref trumps all
665     return 1 if ($syspref eq 'hardyes');
666     return 0 if ($syspref eq 'hardno');
667     ## Now, patron pref trumps all
668     return 1 if ($self->checkprevcheckout eq 'yes');
669     return 0 if ($self->checkprevcheckout eq 'no');
670
671     # More complex: patron inherits -> determine category preference
672     my $checkPrevCheckoutByCat = $self->category->checkprevcheckout;
673     return 1 if ($checkPrevCheckoutByCat eq 'yes');
674     return 0 if ($checkPrevCheckoutByCat eq 'no');
675
676     # Finally: category preference is inherit, default to 0
677     if ($syspref eq 'softyes') {
678         return 1;
679     } else {
680         return 0;
681     }
682 }
683
684 =head3 do_check_for_previous_checkout
685
686     $do_check = $patron->do_check_for_previous_checkout($item);
687
688 Return 1 if the bib associated with $ITEM has previously been checked out to
689 $PATRON, 0 otherwise.
690
691 =cut
692
693 sub do_check_for_previous_checkout {
694     my ( $self, $item ) = @_;
695
696     my @item_nos;
697     my $biblio = Koha::Biblios->find( $item->{biblionumber} );
698     if ( $biblio->is_serial ) {
699         push @item_nos, $item->{itemnumber};
700     } else {
701         # Get all itemnumbers for given bibliographic record.
702         @item_nos = $biblio->items->get_column( 'itemnumber' );
703     }
704
705     # Create (old)issues search criteria
706     my $criteria = {
707         borrowernumber => $self->borrowernumber,
708         itemnumber => \@item_nos,
709     };
710
711     my $delay = C4::Context->preference('CheckPrevCheckoutDelay') || 0;
712     if ($delay) {
713         my $dtf = Koha::Database->new->schema->storage->datetime_parser;
714         my $newer_than = dt_from_string()->subtract( days => $delay );
715         $criteria->{'returndate'} = { '>'   =>  $dtf->format_datetime($newer_than), };
716     }
717
718     # Check current issues table
719     my $issues = Koha::Checkouts->search($criteria);
720     return 1 if $issues->count; # 0 || N
721
722     # Check old issues table
723     my $old_issues = Koha::Old::Checkouts->search($criteria);
724     return $old_issues->count;  # 0 || N
725 }
726
727 =head3 is_debarred
728
729 my $debarment_expiration = $patron->is_debarred;
730
731 Returns the date a patron debarment will expire, or undef if the patron is not
732 debarred
733
734 =cut
735
736 sub is_debarred {
737     my ($self) = @_;
738
739     return unless $self->debarred;
740     return $self->debarred
741       if $self->debarred =~ '^9999'
742       or dt_from_string( $self->debarred ) > dt_from_string;
743     return;
744 }
745
746 =head3 is_expired
747
748 my $is_expired = $patron->is_expired;
749
750 Returns 1 if the patron is expired or 0;
751
752 =cut
753
754 sub is_expired {
755     my ($self) = @_;
756     return 0 unless $self->dateexpiry;
757     return 0 if $self->dateexpiry =~ '^9999';
758     return 1 if dt_from_string( $self->dateexpiry ) < dt_from_string->truncate( to => 'day' );
759     return 0;
760 }
761
762 =head3 is_going_to_expire
763
764 my $is_going_to_expire = $patron->is_going_to_expire;
765
766 Returns 1 if the patron is going to expired, depending on the NotifyBorrowerDeparture pref or 0
767
768 =cut
769
770 sub is_going_to_expire {
771     my ($self) = @_;
772
773     my $delay = C4::Context->preference('NotifyBorrowerDeparture') || 0;
774
775     return 0 unless $delay;
776     return 0 unless $self->dateexpiry;
777     return 0 if $self->dateexpiry =~ '^9999';
778     return 1 if dt_from_string( $self->dateexpiry, undef, 'floating' )->subtract( days => $delay ) < dt_from_string(undef, undef, 'floating')->truncate( to => 'day' );
779     return 0;
780 }
781
782 =head3 set_password
783
784     $patron->set_password({ password => $plain_text_password [, skip_validation => 1 ] });
785
786 Set the patron's password.
787
788 =head4 Exceptions
789
790 The passed string is validated against the current password enforcement policy.
791 Validation can be skipped by passing the I<skip_validation> parameter.
792
793 Exceptions are thrown if the password is not good enough.
794
795 =over 4
796
797 =item Koha::Exceptions::Password::TooShort
798
799 =item Koha::Exceptions::Password::WhitespaceCharacters
800
801 =item Koha::Exceptions::Password::TooWeak
802
803 =item Koha::Exceptions::Password::Plugin (if a "check password" plugin is enabled)
804
805 =back
806
807 =cut
808
809 sub set_password {
810     my ( $self, $args ) = @_;
811
812     my $password = $args->{password};
813
814     unless ( $args->{skip_validation} ) {
815         my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $password, $self->category );
816
817         if ( !$is_valid ) {
818             if ( $error eq 'too_short' ) {
819                 my $min_length = $self->category->effective_min_password_length;
820                 $min_length = 3 if not $min_length or $min_length < 3;
821
822                 my $password_length = length($password);
823                 Koha::Exceptions::Password::TooShort->throw(
824                     length => $password_length, min_length => $min_length );
825             }
826             elsif ( $error eq 'has_whitespaces' ) {
827                 Koha::Exceptions::Password::WhitespaceCharacters->throw();
828             }
829             elsif ( $error eq 'too_weak' ) {
830                 Koha::Exceptions::Password::TooWeak->throw();
831             }
832         }
833     }
834
835     if ( C4::Context->config("enable_plugins") ) {
836         # Call any check_password plugins
837         my @plugins = Koha::Plugins->new()->GetPlugins({
838             method => 'check_password',
839         });
840         foreach my $plugin ( @plugins ) {
841             # This plugin hook will also be used by a plugin for the Norwegian national
842             # patron database. This is why we need to pass both the password and the
843             # borrowernumber to the plugin.
844             my $ret = $plugin->check_password(
845                 {
846                     password       => $password,
847                     borrowernumber => $self->borrowernumber
848                 }
849             );
850             # This plugin hook will also be used by a plugin for the Norwegian national
851             # patron database. This is why we need to call the actual plugins and then
852             # check skip_validation afterwards.
853             if ( $ret->{'error'} == 1 && !$args->{skip_validation} ) {
854                 Koha::Exceptions::Password::Plugin->throw();
855             }
856         }
857     }
858
859     my $digest = Koha::AuthUtils::hash_password($password);
860
861     # We do not want to call $self->store and retrieve password from DB
862     $self->password($digest);
863     $self->login_attempts(0);
864     $self->SUPER::store;
865
866     logaction( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" )
867         if C4::Context->preference("BorrowersLog");
868
869     return $self;
870 }
871
872
873 =head3 renew_account
874
875 my $new_expiry_date = $patron->renew_account
876
877 Extending the subscription to the expiry date.
878
879 =cut
880
881 sub renew_account {
882     my ($self) = @_;
883     my $date;
884     if ( C4::Context->preference('BorrowerRenewalPeriodBase') eq 'combination' ) {
885         $date = ( dt_from_string gt dt_from_string( $self->dateexpiry ) ) ? dt_from_string : dt_from_string( $self->dateexpiry );
886     } else {
887         $date =
888             C4::Context->preference('BorrowerRenewalPeriodBase') eq 'dateexpiry'
889             ? dt_from_string( $self->dateexpiry )
890             : dt_from_string;
891     }
892     my $expiry_date = $self->category->get_expiry_date($date);
893
894     $self->dateexpiry($expiry_date);
895     $self->date_renewed( dt_from_string() );
896     $self->store();
897
898     $self->add_enrolment_fee_if_needed(1);
899
900     logaction( "MEMBERS", "RENEW", $self->borrowernumber, "Membership renewed" ) if C4::Context->preference("BorrowersLog");
901     return dt_from_string( $expiry_date )->truncate( to => 'day' );
902 }
903
904 =head3 has_overdues
905
906 my $has_overdues = $patron->has_overdues;
907
908 Returns the number of patron's overdues
909
910 =cut
911
912 sub has_overdues {
913     my ($self) = @_;
914     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
915     return $self->_result->issues->search({ date_due => { '<' => $dtf->format_datetime( dt_from_string() ) } })->count;
916 }
917
918 =head3 track_login
919
920     $patron->track_login;
921     $patron->track_login({ force => 1 });
922
923     Tracks a (successful) login attempt.
924     The preference TrackLastPatronActivity must be enabled. Or you
925     should pass the force parameter.
926
927 =cut
928
929 sub track_login {
930     my ( $self, $params ) = @_;
931     return if
932         !$params->{force} &&
933         !C4::Context->preference('TrackLastPatronActivity');
934     $self->lastseen( dt_from_string() )->store;
935 }
936
937 =head3 move_to_deleted
938
939 my $is_moved = $patron->move_to_deleted;
940
941 Move a patron to the deletedborrowers table.
942 This can be done before deleting a patron, to make sure the data are not completely deleted.
943
944 =cut
945
946 sub move_to_deleted {
947     my ($self) = @_;
948     my $patron_infos = $self->unblessed;
949     delete $patron_infos->{updated_on}; #This ensures the updated_on date in deletedborrowers will be set to the current timestamp
950     return Koha::Database->new->schema->resultset('Deletedborrower')->create($patron_infos);
951 }
952
953 =head3 article_requests
954
955 my @requests = $borrower->article_requests();
956 my $requests = $borrower->article_requests();
957
958 Returns either a list of ArticleRequests objects,
959 or an ArtitleRequests object, depending on the
960 calling context.
961
962 =cut
963
964 sub article_requests {
965     my ( $self ) = @_;
966
967     $self->{_article_requests} ||= Koha::ArticleRequests->search({ borrowernumber => $self->borrowernumber() });
968
969     return $self->{_article_requests};
970 }
971
972 =head3 article_requests_current
973
974 my @requests = $patron->article_requests_current
975
976 Returns the article requests associated with this patron that are incomplete
977
978 =cut
979
980 sub article_requests_current {
981     my ( $self ) = @_;
982
983     $self->{_article_requests_current} ||= Koha::ArticleRequests->search(
984         {
985             borrowernumber => $self->id(),
986             -or          => [
987                 { status => Koha::ArticleRequest::Status::Pending },
988                 { status => Koha::ArticleRequest::Status::Processing }
989             ]
990         }
991     );
992
993     return $self->{_article_requests_current};
994 }
995
996 =head3 article_requests_finished
997
998 my @requests = $biblio->article_requests_finished
999
1000 Returns the article requests associated with this patron that are completed
1001
1002 =cut
1003
1004 sub article_requests_finished {
1005     my ( $self, $borrower ) = @_;
1006
1007     $self->{_article_requests_finished} ||= Koha::ArticleRequests->search(
1008         {
1009             borrowernumber => $self->id(),
1010             -or          => [
1011                 { status => Koha::ArticleRequest::Status::Completed },
1012                 { status => Koha::ArticleRequest::Status::Canceled }
1013             ]
1014         }
1015     );
1016
1017     return $self->{_article_requests_finished};
1018 }
1019
1020 =head3 add_enrolment_fee_if_needed
1021
1022 my $enrolment_fee = $patron->add_enrolment_fee_if_needed($renewal);
1023
1024 Add enrolment fee for a patron if needed.
1025
1026 $renewal - boolean denoting whether this is an account renewal or not
1027
1028 =cut
1029
1030 sub add_enrolment_fee_if_needed {
1031     my ($self, $renewal) = @_;
1032     my $enrolment_fee = $self->category->enrolmentfee;
1033     if ( $enrolment_fee && $enrolment_fee > 0 ) {
1034         my $type = $renewal ? 'ACCOUNT_RENEW' : 'ACCOUNT';
1035         $self->account->add_debit(
1036             {
1037                 amount     => $enrolment_fee,
1038                 user_id    => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
1039                 interface  => C4::Context->interface,
1040                 library_id => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
1041                 type       => $type
1042             }
1043         );
1044     }
1045     return $enrolment_fee || 0;
1046 }
1047
1048 =head3 checkouts
1049
1050 my $checkouts = $patron->checkouts
1051
1052 =cut
1053
1054 sub checkouts {
1055     my ($self) = @_;
1056     my $checkouts = $self->_result->issues;
1057     return Koha::Checkouts->_new_from_dbic( $checkouts );
1058 }
1059
1060 =head3 pending_checkouts
1061
1062 my $pending_checkouts = $patron->pending_checkouts
1063
1064 This method will return the same as $self->checkouts, but with a prefetch on
1065 items, biblio and biblioitems.
1066
1067 It has been introduced to replaced the C4::Members::GetPendingIssues subroutine
1068
1069 It should not be used directly, prefer to access fields you need instead of
1070 retrieving all these fields in one go.
1071
1072 =cut
1073
1074 sub pending_checkouts {
1075     my( $self ) = @_;
1076     my $checkouts = $self->_result->issues->search(
1077         {},
1078         {
1079             order_by => [
1080                 { -desc => 'me.timestamp' },
1081                 { -desc => 'issuedate' },
1082                 { -desc => 'issue_id' }, # Sort by issue_id should be enough
1083             ],
1084             prefetch => { item => { biblio => 'biblioitems' } },
1085         }
1086     );
1087     return Koha::Checkouts->_new_from_dbic( $checkouts );
1088 }
1089
1090 =head3 old_checkouts
1091
1092 my $old_checkouts = $patron->old_checkouts
1093
1094 =cut
1095
1096 sub old_checkouts {
1097     my ($self) = @_;
1098     my $old_checkouts = $self->_result->old_issues;
1099     return Koha::Old::Checkouts->_new_from_dbic( $old_checkouts );
1100 }
1101
1102 =head3 get_overdues
1103
1104 my $overdue_items = $patron->get_overdues
1105
1106 Return the overdue items
1107
1108 =cut
1109
1110 sub get_overdues {
1111     my ($self) = @_;
1112     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1113     return $self->checkouts->search(
1114         {
1115             'me.date_due' => { '<' => $dtf->format_datetime(dt_from_string) },
1116         },
1117         {
1118             prefetch => { item => { biblio => 'biblioitems' } },
1119         }
1120     );
1121 }
1122
1123 =head3 get_routing_lists
1124
1125 my @routinglists = $patron->get_routing_lists
1126
1127 Returns the routing lists a patron is subscribed to.
1128
1129 =cut
1130
1131 sub get_routing_lists {
1132     my ($self) = @_;
1133     my $routing_list_rs = $self->_result->subscriptionroutinglists;
1134     return Koha::Subscription::Routinglists->_new_from_dbic($routing_list_rs);
1135 }
1136
1137 =head3 get_age
1138
1139 my $age = $patron->get_age
1140
1141 Return the age of the patron
1142
1143 =cut
1144
1145 sub get_age {
1146     my ($self)    = @_;
1147     my $today_str = dt_from_string->strftime("%Y-%m-%d");
1148     return unless $self->dateofbirth;
1149     my $dob_str   = dt_from_string( $self->dateofbirth )->strftime("%Y-%m-%d");
1150
1151     my ( $dob_y,   $dob_m,   $dob_d )   = split /-/, $dob_str;
1152     my ( $today_y, $today_m, $today_d ) = split /-/, $today_str;
1153
1154     my $age = $today_y - $dob_y;
1155     if ( $dob_m . $dob_d > $today_m . $today_d ) {
1156         $age--;
1157     }
1158
1159     return $age;
1160 }
1161
1162 =head3 is_valid_age
1163
1164 my $is_valid = $patron->is_valid_age
1165
1166 Return 1 if patron's age is between allowed limits, returns 0 if it's not.
1167
1168 =cut
1169
1170 sub is_valid_age {
1171     my ($self) = @_;
1172     my $age = $self->get_age;
1173
1174     my $patroncategory = $self->category;
1175     my ($low,$high) = ($patroncategory->dateofbirthrequired, $patroncategory->upperagelimit);
1176
1177     return (defined($age) && (($high && ($age > $high)) or ($low && ($age < $low)))) ? 0 : 1;
1178 }
1179
1180 =head3 account
1181
1182 my $account = $patron->account
1183
1184 =cut
1185
1186 sub account {
1187     my ($self) = @_;
1188     return Koha::Account->new( { patron_id => $self->borrowernumber } );
1189 }
1190
1191 =head3 holds
1192
1193 my $holds = $patron->holds
1194
1195 Return all the holds placed by this patron
1196
1197 =cut
1198
1199 sub holds {
1200     my ($self) = @_;
1201     my $holds_rs = $self->_result->reserves->search( {}, { order_by => 'reservedate' } );
1202     return Koha::Holds->_new_from_dbic($holds_rs);
1203 }
1204
1205 =head3 old_holds
1206
1207 my $old_holds = $patron->old_holds
1208
1209 Return all the historical holds for this patron
1210
1211 =cut
1212
1213 sub old_holds {
1214     my ($self) = @_;
1215     my $old_holds_rs = $self->_result->old_reserves->search( {}, { order_by => 'reservedate' } );
1216     return Koha::Old::Holds->_new_from_dbic($old_holds_rs);
1217 }
1218
1219 =head3 return_claims
1220
1221 my $return_claims = $patron->return_claims
1222
1223 =cut
1224
1225 sub return_claims {
1226     my ($self) = @_;
1227     my $return_claims = $self->_result->return_claims_borrowernumbers;
1228     return Koha::Checkouts::ReturnClaims->_new_from_dbic( $return_claims );
1229 }
1230
1231 =head3 notice_email_address
1232
1233   my $email = $patron->notice_email_address;
1234
1235 Return the email address of patron used for notices.
1236 Returns the empty string if no email address.
1237
1238 =cut
1239
1240 sub notice_email_address{
1241     my ( $self ) = @_;
1242
1243     my $which_address = C4::Context->preference("AutoEmailPrimaryAddress");
1244     # if syspref is set to 'first valid' (value == OFF), look up email address
1245     if ( $which_address eq 'OFF' ) {
1246         return $self->first_valid_email_address;
1247     }
1248
1249     return $self->$which_address || '';
1250 }
1251
1252 =head3 first_valid_email_address
1253
1254 my $first_valid_email_address = $patron->first_valid_email_address
1255
1256 Return the first valid email address for a patron.
1257 For now, the order  is defined as email, emailpro, B_email.
1258 Returns the empty string if the borrower has no email addresses.
1259
1260 =cut
1261
1262 sub first_valid_email_address {
1263     my ($self) = @_;
1264
1265     return $self->email() || $self->emailpro() || $self->B_email() || q{};
1266 }
1267
1268 =head3 get_club_enrollments
1269
1270 =cut
1271
1272 sub get_club_enrollments {
1273     my ( $self, $return_scalar ) = @_;
1274
1275     my $e = Koha::Club::Enrollments->search( { borrowernumber => $self->borrowernumber(), date_canceled => undef } );
1276
1277     return $e if $return_scalar;
1278
1279     return wantarray ? $e->as_list : $e;
1280 }
1281
1282 =head3 get_enrollable_clubs
1283
1284 =cut
1285
1286 sub get_enrollable_clubs {
1287     my ( $self, $is_enrollable_from_opac, $return_scalar ) = @_;
1288
1289     my $params;
1290     $params->{is_enrollable_from_opac} = $is_enrollable_from_opac
1291       if $is_enrollable_from_opac;
1292     $params->{is_email_required} = 0 unless $self->first_valid_email_address();
1293
1294     $params->{borrower} = $self;
1295
1296     my $e = Koha::Clubs->get_enrollable($params);
1297
1298     return $e if $return_scalar;
1299
1300     return wantarray ? $e->as_list : $e;
1301 }
1302
1303 =head3 account_locked
1304
1305 my $is_locked = $patron->account_locked
1306
1307 Return true if the patron has reached the maximum number of login attempts
1308 (see pref FailedLoginAttempts). If login_attempts is < 0, this is interpreted
1309 as an administrative lockout (independent of FailedLoginAttempts; see also
1310 Koha::Patron->lock).
1311 Otherwise return false.
1312 If the pref is not set (empty string, null or 0), the feature is considered as
1313 disabled.
1314
1315 =cut
1316
1317 sub account_locked {
1318     my ($self) = @_;
1319     my $FailedLoginAttempts = C4::Context->preference('FailedLoginAttempts');
1320     return 1 if $FailedLoginAttempts
1321           and $self->login_attempts
1322           and $self->login_attempts >= $FailedLoginAttempts;
1323     return 1 if ($self->login_attempts || 0) < 0; # administrative lockout
1324     return 0;
1325 }
1326
1327 =head3 can_see_patron_infos
1328
1329 my $can_see = $patron->can_see_patron_infos( $patron );
1330
1331 Return true if the patron (usually the logged in user) can see the patron's infos for a given patron
1332
1333 =cut
1334
1335 sub can_see_patron_infos {
1336     my ( $self, $patron ) = @_;
1337     return unless $patron;
1338     return $self->can_see_patrons_from( $patron->library->branchcode );
1339 }
1340
1341 =head3 can_see_patrons_from
1342
1343 my $can_see = $patron->can_see_patrons_from( $branchcode );
1344
1345 Return true if the patron (usually the logged in user) can see the patron's infos from a given library
1346
1347 =cut
1348
1349 sub can_see_patrons_from {
1350     my ( $self, $branchcode ) = @_;
1351     my $can = 0;
1352     if ( $self->branchcode eq $branchcode ) {
1353         $can = 1;
1354     } elsif ( $self->has_permission( { borrowers => 'view_borrower_infos_from_any_libraries' } ) ) {
1355         $can = 1;
1356     } elsif ( my $library_groups = $self->library->library_groups ) {
1357         while ( my $library_group = $library_groups->next ) {
1358             if ( $library_group->parent->has_child( $branchcode ) ) {
1359                 $can = 1;
1360                 last;
1361             }
1362         }
1363     }
1364     return $can;
1365 }
1366
1367 =head3 libraries_where_can_see_patrons
1368
1369 my $libraries = $patron-libraries_where_can_see_patrons;
1370
1371 Return the list of branchcodes(!) of libraries the patron is allowed to see other patron's infos.
1372 The branchcodes are arbitrarily returned sorted.
1373 We are supposing here that the object is related to the logged in patron (use of C4::Context::only_my_library)
1374
1375 An empty array means no restriction, the patron can see patron's infos from any libraries.
1376
1377 =cut
1378
1379 sub libraries_where_can_see_patrons {
1380     my ( $self ) = @_;
1381     my $userenv = C4::Context->userenv;
1382
1383     return () unless $userenv; # For tests, but userenv should be defined in tests...
1384
1385     my @restricted_branchcodes;
1386     if (C4::Context::only_my_library) {
1387         push @restricted_branchcodes, $self->branchcode;
1388     }
1389     else {
1390         unless (
1391             $self->has_permission(
1392                 { borrowers => 'view_borrower_infos_from_any_libraries' }
1393             )
1394           )
1395         {
1396             my $library_groups = $self->library->library_groups({ ft_hide_patron_info => 1 });
1397             if ( $library_groups->count )
1398             {
1399                 while ( my $library_group = $library_groups->next ) {
1400                     my $parent = $library_group->parent;
1401                     if ( $parent->has_child( $self->branchcode ) ) {
1402                         push @restricted_branchcodes, $parent->children->get_column('branchcode');
1403                     }
1404                 }
1405             }
1406
1407             @restricted_branchcodes = ( $self->branchcode ) unless @restricted_branchcodes;
1408         }
1409     }
1410
1411     @restricted_branchcodes = grep { defined $_ } @restricted_branchcodes;
1412     @restricted_branchcodes = uniq(@restricted_branchcodes);
1413     @restricted_branchcodes = sort(@restricted_branchcodes);
1414     return @restricted_branchcodes;
1415 }
1416
1417 =head3 has_permission
1418
1419 my $permission = $patron->has_permission($required);
1420
1421 See C4::Auth::haspermission for details of syntax for $required
1422
1423 =cut
1424
1425 sub has_permission {
1426     my ( $self, $flagsrequired ) = @_;
1427     return unless $self->userid;
1428     # TODO code from haspermission needs to be moved here!
1429     return C4::Auth::haspermission( $self->userid, $flagsrequired );
1430 }
1431
1432 =head3 is_superlibrarian
1433
1434   my $is_superlibrarian = $patron->is_superlibrarian;
1435
1436 Return true if the patron is a superlibrarian.
1437
1438 =cut
1439
1440 sub is_superlibrarian {
1441     my ($self) = @_;
1442     return $self->has_permission( { superlibrarian => 1 } ) ? 1 : 0;
1443 }
1444
1445 =head3 is_adult
1446
1447 my $is_adult = $patron->is_adult
1448
1449 Return true if the patron has a category with a type Adult (A) or Organization (I)
1450
1451 =cut
1452
1453 sub is_adult {
1454     my ( $self ) = @_;
1455     return $self->category->category_type =~ /^(A|I)$/ ? 1 : 0;
1456 }
1457
1458 =head3 is_child
1459
1460 my $is_child = $patron->is_child
1461
1462 Return true if the patron has a category with a type Child (C)
1463
1464 =cut
1465
1466 sub is_child {
1467     my( $self ) = @_;
1468     return $self->category->category_type eq 'C' ? 1 : 0;
1469 }
1470
1471 =head3 has_valid_userid
1472
1473 my $patron = Koha::Patrons->find(42);
1474 $patron->userid( $new_userid );
1475 my $has_a_valid_userid = $patron->has_valid_userid
1476
1477 my $patron = Koha::Patron->new( $params );
1478 my $has_a_valid_userid = $patron->has_valid_userid
1479
1480 Return true if the current userid of this patron is valid/unique, otherwise false.
1481
1482 Note that this should be done in $self->store instead and raise an exception if needed.
1483
1484 =cut
1485
1486 sub has_valid_userid {
1487     my ($self) = @_;
1488
1489     return 0 unless $self->userid;
1490
1491     return 0 if ( $self->userid eq C4::Context->config('user') );    # DB user
1492
1493     my $already_exists = Koha::Patrons->search(
1494         {
1495             userid => $self->userid,
1496             (
1497                 $self->in_storage
1498                 ? ( borrowernumber => { '!=' => $self->borrowernumber } )
1499                 : ()
1500             ),
1501         }
1502     )->count;
1503     return $already_exists ? 0 : 1;
1504 }
1505
1506 =head3 generate_userid
1507
1508 my $patron = Koha::Patron->new( $params );
1509 $patron->generate_userid
1510
1511 Generate a userid using the $surname and the $firstname (if there is a value in $firstname).
1512
1513 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).
1514
1515 =cut
1516
1517 sub generate_userid {
1518     my ($self) = @_;
1519     my $offset = 0;
1520     my $firstname = $self->firstname // q{};
1521     my $surname = $self->surname // q{};
1522     #The script will "do" the following code and increment the $offset until the generated userid is unique
1523     do {
1524       $firstname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1525       $surname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1526       my $userid = lc(($firstname)? "$firstname.$surname" : $surname);
1527       $userid = NFKD( $userid );
1528       $userid =~ s/\p{NonspacingMark}//g;
1529       $userid .= $offset unless $offset == 0;
1530       $self->userid( $userid );
1531       $offset++;
1532      } while (! $self->has_valid_userid );
1533
1534      return $self;
1535 }
1536
1537 =head3 add_extended_attribute
1538
1539 =cut
1540
1541 sub add_extended_attribute {
1542     my ($self, $attribute) = @_;
1543
1544     return Koha::Patron::Attribute->new(
1545         {
1546             %$attribute,
1547             ( borrowernumber => $self->borrowernumber ),
1548         }
1549     )->store;
1550
1551 }
1552
1553 =head3 extended_attributes
1554
1555 Return object of Koha::Patron::Attributes type with all attributes set for this patron
1556
1557 Or setter FIXME
1558
1559 =cut
1560
1561 sub extended_attributes {
1562     my ( $self, $attributes ) = @_;
1563     if ($attributes) {    # setter
1564         my $schema = $self->_result->result_source->schema;
1565         $schema->txn_do(
1566             sub {
1567                 # Remove the existing one
1568                 $self->extended_attributes->filter_by_branch_limitations->delete;
1569
1570                 # Insert the new ones
1571                 my $new_types = {};
1572                 for my $attribute (@$attributes) {
1573                     $self->add_extended_attribute($attribute);
1574                     $new_types->{$attribute->{code}} = 1;
1575                 }
1576
1577                 # Check globally mandatory types
1578                 my @required_attribute_types =
1579                     Koha::Patron::Attribute::Types->search(
1580                         {
1581                             mandatory => 1,
1582                             'borrower_attribute_types_branches.b_branchcode' =>
1583                               undef
1584                         },
1585                         { join => 'borrower_attribute_types_branches' }
1586                     )->get_column('code');
1587                 for my $type ( @required_attribute_types ) {
1588                     Koha::Exceptions::Patron::MissingMandatoryExtendedAttribute->throw(
1589                         type => $type,
1590                     ) if !$new_types->{$type};
1591                 }
1592             }
1593         );
1594     }
1595
1596     my $rs = $self->_result->borrower_attributes;
1597     # We call search to use the filters in Koha::Patron::Attributes->search
1598     return Koha::Patron::Attributes->_new_from_dbic($rs)->search;
1599 }
1600
1601 =head3 lock
1602
1603     Koha::Patrons->find($id)->lock({ expire => 1, remove => 1 });
1604
1605     Lock and optionally expire a patron account.
1606     Remove holds and article requests if remove flag set.
1607     In order to distinguish from locking by entering a wrong password, let's
1608     call this an administrative lockout.
1609
1610 =cut
1611
1612 sub lock {
1613     my ( $self, $params ) = @_;
1614     $self->login_attempts( ADMINISTRATIVE_LOCKOUT );
1615     if( $params->{expire} ) {
1616         $self->dateexpiry( dt_from_string->subtract(days => 1) );
1617     }
1618     $self->store;
1619     if( $params->{remove} ) {
1620         $self->holds->delete;
1621         $self->article_requests->delete;
1622     }
1623     return $self;
1624 }
1625
1626 =head3 anonymize
1627
1628     Koha::Patrons->find($id)->anonymize;
1629
1630     Anonymize or clear borrower fields. Fields in BorrowerMandatoryField
1631     are randomized, other personal data is cleared too.
1632     Patrons with issues are skipped.
1633
1634 =cut
1635
1636 sub anonymize {
1637     my ( $self ) = @_;
1638     if( $self->_result->issues->count ) {
1639         warn "Exiting anonymize: patron ".$self->borrowernumber." still has issues";
1640         return;
1641     }
1642     # Mandatory fields come from the corresponding pref, but email fields
1643     # are removed since scrambled email addresses only generate errors
1644     my $mandatory = { map { (lc $_, 1); } grep { !/email/ }
1645         split /\s*\|\s*/, C4::Context->preference('BorrowerMandatoryField') };
1646     $mandatory->{userid} = 1; # needed since sub store does not clear field
1647     my @columns = $self->_result->result_source->columns;
1648     @columns = grep { !/borrowernumber|branchcode|categorycode|^date|password|flags|updated_on|lastseen|lang|login_attempts|anonymized/ } @columns;
1649     push @columns, 'dateofbirth'; # add this date back in
1650     foreach my $col (@columns) {
1651         $self->_anonymize_column($col, $mandatory->{lc $col} );
1652     }
1653     $self->anonymized(1)->store;
1654 }
1655
1656 sub _anonymize_column {
1657     my ( $self, $col, $mandatory ) = @_;
1658     my $col_info = $self->_result->result_source->column_info($col);
1659     my $type = $col_info->{data_type};
1660     my $nullable = $col_info->{is_nullable};
1661     my $val;
1662     if( $type =~ /char|text/ ) {
1663         $val = $mandatory
1664             ? Koha::Token->new->generate({ pattern => '\w{10}' })
1665             : $nullable
1666             ? undef
1667             : q{};
1668     } elsif( $type =~ /integer|int$|float|dec|double/ ) {
1669         $val = $nullable ? undef : 0;
1670     } elsif( $type =~ /date|time/ ) {
1671         $val = $nullable ? undef : dt_from_string;
1672     }
1673     $self->$col($val);
1674 }
1675
1676 =head3 add_guarantor
1677
1678     my @relationships = $patron->add_guarantor(
1679         {
1680             borrowernumber => $borrowernumber,
1681             relationships  => $relationship,
1682         }
1683     );
1684
1685     Adds a new guarantor to a patron.
1686
1687 =cut
1688
1689 sub add_guarantor {
1690     my ( $self, $params ) = @_;
1691
1692     my $guarantor_id = $params->{guarantor_id};
1693     my $relationship = $params->{relationship};
1694
1695     return Koha::Patron::Relationship->new(
1696         {
1697             guarantee_id => $self->id,
1698             guarantor_id => $guarantor_id,
1699             relationship => $relationship
1700         }
1701     )->store();
1702 }
1703
1704 =head3 get_extended_attribute
1705
1706 my $attribute_value = $patron->get_extended_attribute( $code );
1707
1708 Return the attribute for the code passed in parameter.
1709
1710 It not exist it returns undef
1711
1712 Note that this will not work for repeatable attribute types.
1713
1714 Maybe you certainly not want to use this method, it is actually only used for SHOW_BARCODE
1715 (which should be a real patron's attribute (not extended)
1716
1717 =cut
1718
1719 sub get_extended_attribute {
1720     my ( $self, $code, $value ) = @_;
1721     my $rs = $self->_result->borrower_attributes;
1722     return unless $rs;
1723     my $attribute = $rs->search({ code => $code, ( $value ? ( attribute => $value ) : () ) });
1724     return unless $attribute->count;
1725     return $attribute->next;
1726 }
1727
1728 =head3 to_api
1729
1730     my $json = $patron->to_api;
1731
1732 Overloaded method that returns a JSON representation of the Koha::Patron object,
1733 suitable for API output.
1734
1735 =cut
1736
1737 sub to_api {
1738     my ( $self, $params ) = @_;
1739
1740     my $json_patron = $self->SUPER::to_api( $params );
1741
1742     $json_patron->{restricted} = ( $self->is_debarred )
1743                                     ? Mojo::JSON->true
1744                                     : Mojo::JSON->false;
1745
1746     return $json_patron;
1747 }
1748
1749 =head3 to_api_mapping
1750
1751 This method returns the mapping for representing a Koha::Patron object
1752 on the API.
1753
1754 =cut
1755
1756 sub to_api_mapping {
1757     return {
1758         borrowernotes       => 'staff_notes',
1759         borrowernumber      => 'patron_id',
1760         branchcode          => 'library_id',
1761         categorycode        => 'category_id',
1762         checkprevcheckout   => 'check_previous_checkout',
1763         contactfirstname    => undef,                     # Unused
1764         contactname         => undef,                     # Unused
1765         contactnote         => 'altaddress_notes',
1766         contacttitle        => undef,                     # Unused
1767         dateenrolled        => 'date_enrolled',
1768         dateexpiry          => 'expiry_date',
1769         dateofbirth         => 'date_of_birth',
1770         debarred            => undef,                     # replaced by 'restricted'
1771         debarredcomment     => undef,    # calculated, API consumers will use /restrictions instead
1772         emailpro            => 'secondary_email',
1773         flags               => undef,    # permissions manipulation handled in /permissions
1774         gonenoaddress       => 'incorrect_address',
1775         guarantorid         => 'guarantor_id',
1776         lastseen            => 'last_seen',
1777         lost                => 'patron_card_lost',
1778         opacnote            => 'opac_notes',
1779         othernames          => 'other_name',
1780         password            => undef,            # password manipulation handled in /password
1781         phonepro            => 'secondary_phone',
1782         relationship        => 'relationship_type',
1783         sex                 => 'gender',
1784         smsalertnumber      => 'sms_number',
1785         sort1               => 'statistics_1',
1786         sort2               => 'statistics_2',
1787         autorenew_checkouts => 'autorenew_checkouts',
1788         streetnumber        => 'street_number',
1789         streettype          => 'street_type',
1790         zipcode             => 'postal_code',
1791         B_address           => 'altaddress_address',
1792         B_address2          => 'altaddress_address2',
1793         B_city              => 'altaddress_city',
1794         B_country           => 'altaddress_country',
1795         B_email             => 'altaddress_email',
1796         B_phone             => 'altaddress_phone',
1797         B_state             => 'altaddress_state',
1798         B_streetnumber      => 'altaddress_street_number',
1799         B_streettype        => 'altaddress_street_type',
1800         B_zipcode           => 'altaddress_postal_code',
1801         altcontactaddress1  => 'altcontact_address',
1802         altcontactaddress2  => 'altcontact_address2',
1803         altcontactaddress3  => 'altcontact_city',
1804         altcontactcountry   => 'altcontact_country',
1805         altcontactfirstname => 'altcontact_firstname',
1806         altcontactphone     => 'altcontact_phone',
1807         altcontactsurname   => 'altcontact_surname',
1808         altcontactstate     => 'altcontact_state',
1809         altcontactzipcode   => 'altcontact_postal_code'
1810     };
1811 }
1812
1813 =head3 send_notice
1814
1815     Koha::Patrons->send_notice({ letter_params => $letter_params, message_name => 'DUE'});
1816     Koha::Patrons->send_notice({ letter_params => $letter_params, message_transports => \@message_transports });
1817     Koha::Patrons->send_notice({ letter_params => $letter_params, message_transports => \@message_transports, test_mode => 1 });
1818
1819     Queue messages to a patron. Can pass a message that is part of the message_attributes
1820     table or supply the transport to use.
1821
1822     If passed a message name we retrieve the patrons preferences for transports
1823     Otherwise we use the supplied transport. In the case of email or sms we fall back to print if
1824     we have no address/number for sending
1825
1826     $letter_params is a hashref of the values to be passed to GetPreparedLetter
1827
1828     test_mode will only report which notices would be sent, but nothign will be queued
1829
1830 =cut
1831
1832 sub send_notice {
1833     my ( $self, $params ) = @_;
1834     my $letter_params = $params->{letter_params};
1835     my $test_mode = $params->{test_mode};
1836
1837     return unless $letter_params;
1838     return unless exists $params->{message_name} xor $params->{message_transports}; # We only want one of these
1839
1840     my $library = Koha::Libraries->find( $letter_params->{branchcode} )->unblessed;
1841     my $admin_email_address = $library->{branchemail} || C4::Context->preference('KohaAdminEmailAddress');
1842
1843     my @message_transports;
1844     my $letter_code;
1845     $letter_code = $letter_params->{letter_code};
1846     if( $params->{message_name} ){
1847         my $messaging_prefs = C4::Members::Messaging::GetMessagingPreferences( {
1848                 borrowernumber => $letter_params->{borrowernumber},
1849                 message_name => $params->{message_name}
1850         } );
1851         @message_transports = ( keys %{ $messaging_prefs->{transports} } );
1852         $letter_code = $messaging_prefs->{transports}->{$message_transports[0]} unless $letter_code;
1853     } else {
1854         @message_transports = @{$params->{message_transports}};
1855     }
1856     return unless defined $letter_code;
1857     $letter_params->{letter_code} = $letter_code;
1858     my $print_sent = 0;
1859     my %return;
1860     foreach my $mtt (@message_transports){
1861         next if ($mtt eq 'phone' and C4::Context->preference('TalkingTechItivaPhoneNotification') );
1862         # Phone notices are handled by TalkingTech_itiva_outbound.pl
1863         if( ($mtt eq 'email' and not $self->notice_email_address) or ($mtt eq 'sms' and not $self->smsalertnumber) ){
1864             push @{$return{fallback}}, $mtt;
1865             $mtt = 'print';
1866         }
1867         next if $mtt eq 'print' && $print_sent;
1868         $letter_params->{message_transport_type} = $mtt;
1869         my $letter = C4::Letters::GetPreparedLetter( %$letter_params );
1870         C4::Letters::EnqueueLetter({
1871             letter => $letter,
1872             borrowernumber => $self->borrowernumber,
1873             from_address   => $admin_email_address,
1874             message_transport_type => $mtt
1875         }) unless $test_mode;
1876         push @{$return{sent}}, $mtt;
1877         $print_sent = 1 if $mtt eq 'print';
1878     }
1879     return \%return;
1880 }
1881
1882 =head2 Internal methods
1883
1884 =head3 _type
1885
1886 =cut
1887
1888 sub _type {
1889     return 'Borrower';
1890 }
1891
1892 =head1 AUTHORS
1893
1894 Kyle M Hall <kyle@bywatersolutions.com>
1895 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
1896 Martin Renvoize <martin.renvoize@ptfs-europe.com>
1897
1898 =cut
1899
1900 1;