3 # Copyright ByWater Solutions 2014
4 # Copyright PTFS Europe 2016
6 # This file is part of Koha.
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
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.
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.
24 use List
::MoreUtils
qw( any uniq );
25 use JSON
qw( to_json );
26 use Unicode
::Normalize
;
33 use Koha
::Club
::Enrollments
;
36 use Koha
::Exceptions
::Password
;
38 use Koha
::Old
::Checkouts
;
39 use Koha
::Patron
::Attributes
;
40 use Koha
::Patron
::Categories
;
41 use Koha
::Patron
::HouseboundProfile
;
42 use Koha
::Patron
::HouseboundRole
;
43 use Koha
::Patron
::Images
;
44 use Koha
::Patron
::Relationships
;
47 use Koha
::Subscription
::Routinglists
;
49 use Koha
::Virtualshelves
;
51 use base
qw(Koha::Object);
53 use constant ADMINISTRATIVE_LOCKOUT
=> -1;
55 our $RESULTSET_PATRON_ID_MAPPING = {
56 Accountline
=> 'borrowernumber',
57 Aqbasketuser
=> 'borrowernumber',
58 Aqbudget
=> 'budget_owner_id',
59 Aqbudgetborrower
=> 'borrowernumber',
60 ArticleRequest
=> 'borrowernumber',
61 BorrowerAttribute
=> 'borrowernumber',
62 BorrowerDebarment
=> 'borrowernumber',
63 BorrowerFile
=> 'borrowernumber',
64 BorrowerModification
=> 'borrowernumber',
65 ClubEnrollment
=> 'borrowernumber',
66 Issue
=> 'borrowernumber',
67 ItemsLastBorrower
=> 'borrowernumber',
68 Linktracker
=> 'borrowernumber',
69 Message
=> 'borrowernumber',
70 MessageQueue
=> 'borrowernumber',
71 OldIssue
=> 'borrowernumber',
72 OldReserve
=> 'borrowernumber',
73 Rating
=> 'borrowernumber',
74 Reserve
=> 'borrowernumber',
75 Review
=> 'borrowernumber',
76 SearchHistory
=> 'userid',
77 Statistic
=> 'borrowernumber',
78 Suggestion
=> 'suggestedby',
79 TagAll
=> 'borrowernumber',
80 Virtualshelfcontent
=> 'borrowernumber',
81 Virtualshelfshare
=> 'borrowernumber',
82 Virtualshelve
=> 'owner',
87 Koha::Patron - Koha Patron Object class
98 my ( $class, $params ) = @_;
100 return $class->SUPER::new
($params);
103 =head3 fixup_cardnumber
105 Autogenerate next cardnumber from highest value found in database
109 sub fixup_cardnumber
{
111 my $max = Koha
::Patrons
->search({
112 cardnumber
=> {-regexp
=> '^-?[0-9]+$'}
114 select => \'CAST
(cardnumber AS SIGNED
)',
115 as => ['cast_cardnumber
']
116 })->_resultset->get_column('cast_cardnumber
')->max;
117 $self->cardnumber(($max || 0) +1);
120 =head3 trim_whitespace
122 trim whitespace from data which has some non-whitespace in it.
123 Could be moved to Koha::Object if need to be reused
127 sub trim_whitespaces {
130 my $schema = Koha::Database->new->schema;
131 my @columns = $schema->source($self->_type)->columns;
133 for my $column( @columns ) {
134 my $value = $self->$column;
135 if ( defined $value ) {
136 $value =~ s/^\s*|\s*$//g;
137 $self->$column($value);
143 =head3 plain_text_password
145 $patron->plain_text_password( $password );
147 stores a copy of the unencrypted password in the object
148 for use in code before encrypting for db
152 sub plain_text_password {
153 my ( $self, $password ) = @_;
155 $self->{_plain_text_password} = $password;
158 return $self->{_plain_text_password}
159 if $self->{_plain_text_password};
166 Patron specific store method to cleanup record
167 and do other necessary things before saving
175 $self->_result->result_source->schema->txn_do(
178 C4::Context->preference("autoMemberNum")
179 and ( not defined $self->cardnumber
180 or $self->cardnumber eq '' )
183 # Warning: The caller is responsible for locking the members table in write
184 # mode, to avoid database corruption.
185 # We are in a transaction but the table is not locked
186 $self->fixup_cardnumber;
189 unless( $self->category->in_storage ) {
190 Koha::Exceptions::Object::FKConstraint->throw(
191 broken_fk => 'categorycode
',
192 value => $self->categorycode,
196 $self->trim_whitespaces;
198 # Set surname to uppercase if uppercasesurname is true
199 $self->surname( uc($self->surname) )
200 if C4::Context->preference("uppercasesurnames");
202 $self->relationship(undef) # We do not want to store an empty string in this field
203 if defined $self->relationship
204 and $self->relationship eq "";
206 unless ( $self->in_storage ) { #AddMember
208 # Generate a valid userid/login if needed
209 $self->generate_userid
210 if not $self->userid or not $self->has_valid_userid;
212 # Add expiration date if it isn't already there
213 unless ( $self->dateexpiry ) {
214 $self->dateexpiry( $self->category->get_expiry_date );
217 # Add enrollment date if it isn't already there
218 unless ( $self->dateenrolled ) {
219 $self->dateenrolled(dt_from_string
);
222 # Set the privacy depending on the patron's category
223 my $default_privacy = $self->category->default_privacy || q{};
225 $default_privacy eq 'default' ?
1
226 : $default_privacy eq 'never' ?
2
227 : $default_privacy eq 'forever' ?
0
229 $self->privacy($default_privacy);
231 # Call any check_password plugins if password is passed
232 if ( C4
::Context
->preference('UseKohaPlugins')
233 && C4
::Context
->config("enable_plugins")
236 my @plugins = Koha
::Plugins
->new()->GetPlugins({
237 method
=> 'check_password',
239 foreach my $plugin ( @plugins ) {
240 # This plugin hook will also be used by a plugin for the Norwegian national
241 # patron database. This is why we need to pass both the password and the
242 # borrowernumber to the plugin.
243 my $ret = $plugin->check_password(
245 password
=> $self->password,
246 borrowernumber
=> $self->borrowernumber
249 if ( $ret->{'error'} == 1 ) {
250 Koha
::Exceptions
::Password
::Plugin
->throw();
255 # Make a copy of the plain text password for later use
256 $self->plain_text_password( $self->password );
258 # Create a disabled account if no password provided
259 $self->password( $self->password
260 ? Koha
::AuthUtils
::hash_password
( $self->password )
263 $self->borrowernumber(undef);
265 $self = $self->SUPER::store
;
267 $self->add_enrolment_fee_if_needed(0);
269 logaction
( "MEMBERS", "CREATE", $self->borrowernumber, "" )
270 if C4
::Context
->preference("BorrowersLog");
274 my $self_from_storage = $self->get_from_storage;
275 # FIXME We should not deal with that here, callers have to do this job
276 # Moved from ModMember to prevent regressions
277 unless ( $self->userid ) {
278 my $stored_userid = $self_from_storage->userid;
279 $self->userid($stored_userid);
282 # Password must be updated using $self->set_password
283 $self->password($self_from_storage->password);
285 if ( $self->category->categorycode ne
286 $self_from_storage->category->categorycode )
288 # Add enrolement fee on category change if required
289 $self->add_enrolment_fee_if_needed(1)
290 if C4
::Context
->preference('FeeOnChangePatronCategory');
292 # Clean up guarantors on category change if required
293 $self->guarantor_relationships->delete
294 if ( $self->category->category_type ne 'C'
295 && $self->category->category_type ne 'P' );
300 if ( C4
::Context
->preference("BorrowersLog") ) {
302 my $from_storage = $self_from_storage->unblessed;
303 my $from_object = $self->unblessed;
304 my @skip_fields = (qw
/lastseen updated_on/);
305 for my $key ( keys %{$from_storage} ) {
306 next if any
{ /$key/ } @skip_fields;
309 !defined( $from_storage->{$key} )
310 && defined( $from_object->{$key} )
312 || ( defined( $from_storage->{$key} )
313 && !defined( $from_object->{$key} ) )
315 defined( $from_storage->{$key} )
316 && defined( $from_object->{$key} )
317 && ( $from_storage->{$key} ne
318 $from_object->{$key} )
323 before
=> $from_storage->{$key},
324 after
=> $from_object->{$key}
329 if ( defined($info) ) {
333 $self->borrowernumber,
336 { utf8
=> 1, pretty
=> 1, canonical
=> 1 }
343 $self = $self->SUPER::store
;
354 Delete patron's holds, lists and finally the patron.
356 Lists owned by the borrower are deleted, but entries from the borrower to
357 other lists are kept.
364 $self->_result->result_source->schema->txn_do(
366 # Cancel Patron's holds
367 my $holds = $self->holds;
368 while( my $hold = $holds->next ){
372 # Delete all lists and all shares of this borrower
373 # Consistent with the approach Koha uses on deleting individual lists
374 # Note that entries in virtualshelfcontents added by this borrower to
375 # lists of others will be handled by a table constraint: the borrower
376 # is set to NULL in those entries.
378 # We could handle the above deletes via a constraint too.
379 # But a new BZ report 11889 has been opened to discuss another approach.
380 # Instead of deleting we could also disown lists (based on a pref).
381 # In that way we could save shared and public lists.
382 # The current table constraints support that idea now.
383 # This pref should then govern the results of other routines/methods such as
384 # Koha::Virtualshelf->new->delete too.
385 # FIXME Could be $patron->get_lists
386 $_->delete for Koha
::Virtualshelves
->search( { owner
=> $self->borrowernumber } );
388 $self->SUPER::delete;
390 logaction
( "MEMBERS", "DELETE", $self->borrowernumber, "" ) if C4
::Context
->preference("BorrowersLog");
399 my $patron_category = $patron->category
401 Return the patron category for this patron
407 return Koha
::Patron
::Category
->_new_from_dbic( $self->_result->categorycode );
417 return scalar Koha
::Patron
::Images
->find( $self->borrowernumber );
422 Returns a Koha::Library object representing the patron's home library.
428 return Koha
::Library
->_new_from_dbic($self->_result->branchcode);
431 =head3 guarantor_relationships
433 Returns Koha::Patron::Relationships object for this patron's guarantors
435 Returns the set of relationships for the patrons that are guarantors for this patron.
437 This is returned instead of a Koha::Patron object because the guarantor
438 may not exist as a patron in Koha. If this is true, the guarantors name
439 exists in the Koha::Patron::Relationship object and will have no guarantor_id.
443 sub guarantor_relationships
{
446 return Koha
::Patron
::Relationships
->search( { guarantee_id
=> $self->id } );
449 =head3 guarantee_relationships
451 Returns Koha::Patron::Relationships object for this patron's guarantors
453 Returns the set of relationships for the patrons that are guarantees for this patron.
455 The method returns Koha::Patron::Relationship objects for the sake
456 of consistency with the guantors method.
457 A guarantee by definition must exist as a patron in Koha.
461 sub guarantee_relationships
{
464 return Koha
::Patron
::Relationships
->search(
465 { guarantor_id
=> $self->id },
467 prefetch
=> 'guarantee',
468 order_by
=> { -asc
=> [ 'guarantee.surname', 'guarantee.firstname' ] },
473 =head3 housebound_profile
475 Returns the HouseboundProfile associated with this patron.
479 sub housebound_profile
{
481 my $profile = $self->_result->housebound_profile;
482 return Koha
::Patron
::HouseboundProfile
->_new_from_dbic($profile)
487 =head3 housebound_role
489 Returns the HouseboundRole associated with this patron.
493 sub housebound_role
{
496 my $role = $self->_result->housebound_role;
497 return Koha
::Patron
::HouseboundRole
->_new_from_dbic($role) if ( $role );
503 Returns the siblings of this patron.
510 my @guarantors = $self->guarantor_relationships()->guarantors();
512 return unless @guarantors;
515 map { $_->guarantee_relationships()->guarantees() } @guarantors;
517 return unless @siblings;
521 grep { !$seen{ $_->id }++ && ( $_->id != $self->id ) } @siblings;
523 return wantarray ?
@siblings : Koha
::Patrons
->search( { borrowernumber
=> { -in => [ map { $_->id } @siblings ] } } );
528 my $patron = Koha::Patrons->find($id);
529 $patron->merge_with( \@patron_ids );
531 This subroutine merges a list of patrons into the patron record. This is accomplished by finding
532 all related patron ids for the patrons to be merged in other tables and changing the ids to be that
533 of the keeper patron.
538 my ( $self, $patron_ids ) = @_;
540 my @patron_ids = @
{ $patron_ids };
542 # Ensure the keeper isn't in the list of patrons to merge
543 @patron_ids = grep { $_ ne $self->id } @patron_ids;
545 my $schema = Koha
::Database
->new()->schema();
549 $self->_result->result_source->schema->txn_do( sub {
550 foreach my $patron_id (@patron_ids) {
551 my $patron = Koha
::Patrons
->find( $patron_id );
555 # Unbless for safety, the patron will end up being deleted
556 $results->{merged
}->{$patron_id}->{patron
} = $patron->unblessed;
558 while (my ($r, $field) = each(%$RESULTSET_PATRON_ID_MAPPING)) {
559 my $rs = $schema->resultset($r)->search({ $field => $patron_id });
560 $results->{merged
}->{ $patron_id }->{updated
}->{$r} = $rs->count();
561 $rs->update({ $field => $self->id });
564 $patron->move_to_deleted();
574 =head3 wants_check_for_previous_checkout
576 $wants_check = $patron->wants_check_for_previous_checkout;
578 Return 1 if Koha needs to perform PrevIssue checking, else 0.
582 sub wants_check_for_previous_checkout
{
584 my $syspref = C4
::Context
->preference("checkPrevCheckout");
587 ## Hard syspref trumps all
588 return 1 if ($syspref eq 'hardyes');
589 return 0 if ($syspref eq 'hardno');
590 ## Now, patron pref trumps all
591 return 1 if ($self->checkprevcheckout eq 'yes');
592 return 0 if ($self->checkprevcheckout eq 'no');
594 # More complex: patron inherits -> determine category preference
595 my $checkPrevCheckoutByCat = $self->category->checkprevcheckout;
596 return 1 if ($checkPrevCheckoutByCat eq 'yes');
597 return 0 if ($checkPrevCheckoutByCat eq 'no');
599 # Finally: category preference is inherit, default to 0
600 if ($syspref eq 'softyes') {
607 =head3 do_check_for_previous_checkout
609 $do_check = $patron->do_check_for_previous_checkout($item);
611 Return 1 if the bib associated with $ITEM has previously been checked out to
612 $PATRON, 0 otherwise.
616 sub do_check_for_previous_checkout
{
617 my ( $self, $item ) = @_;
620 my $biblio = Koha
::Biblios
->find( $item->{biblionumber
} );
621 if ( $biblio->is_serial ) {
622 push @item_nos, $item->{itemnumber
};
624 # Get all itemnumbers for given bibliographic record.
625 @item_nos = $biblio->items->get_column( 'itemnumber' );
628 # Create (old)issues search criteria
630 borrowernumber
=> $self->borrowernumber,
631 itemnumber
=> \
@item_nos,
634 # Check current issues table
635 my $issues = Koha
::Checkouts
->search($criteria);
636 return 1 if $issues->count; # 0 || N
638 # Check old issues table
639 my $old_issues = Koha
::Old
::Checkouts
->search($criteria);
640 return $old_issues->count; # 0 || N
645 my $debarment_expiration = $patron->is_debarred;
647 Returns the date a patron debarment will expire, or undef if the patron is not
655 return unless $self->debarred;
656 return $self->debarred
657 if $self->debarred =~ '^9999'
658 or dt_from_string
( $self->debarred ) > dt_from_string
;
664 my $is_expired = $patron->is_expired;
666 Returns 1 if the patron is expired or 0;
672 return 0 unless $self->dateexpiry;
673 return 0 if $self->dateexpiry =~ '^9999';
674 return 1 if dt_from_string
( $self->dateexpiry ) < dt_from_string
->truncate( to
=> 'day' );
678 =head3 is_going_to_expire
680 my $is_going_to_expire = $patron->is_going_to_expire;
682 Returns 1 if the patron is going to expired, depending on the NotifyBorrowerDeparture pref or 0
686 sub is_going_to_expire
{
689 my $delay = C4
::Context
->preference('NotifyBorrowerDeparture') || 0;
691 return 0 unless $delay;
692 return 0 unless $self->dateexpiry;
693 return 0 if $self->dateexpiry =~ '^9999';
694 return 1 if dt_from_string
( $self->dateexpiry, undef, 'floating' )->subtract( days
=> $delay ) < dt_from_string
(undef, undef, 'floating')->truncate( to
=> 'day' );
700 $patron->set_password({ password => $plain_text_password [, skip_validation => 1 ] });
702 Set the patron's password.
706 The passed string is validated against the current password enforcement policy.
707 Validation can be skipped by passing the I<skip_validation> parameter.
709 Exceptions are thrown if the password is not good enough.
713 =item Koha::Exceptions::Password::TooShort
715 =item Koha::Exceptions::Password::WhitespaceCharacters
717 =item Koha::Exceptions::Password::TooWeak
719 =item Koha::Exceptions::Password::Plugin (if a "check password" plugin is enabled)
726 my ( $self, $args ) = @_;
728 my $password = $args->{password
};
730 unless ( $args->{skip_validation
} ) {
731 my ( $is_valid, $error ) = Koha
::AuthUtils
::is_password_valid
( $password );
734 if ( $error eq 'too_short' ) {
735 my $min_length = C4
::Context
->preference('minPasswordLength');
736 $min_length = 3 if not $min_length or $min_length < 3;
738 my $password_length = length($password);
739 Koha
::Exceptions
::Password
::TooShort
->throw(
740 length => $password_length, min_length
=> $min_length );
742 elsif ( $error eq 'has_whitespaces' ) {
743 Koha
::Exceptions
::Password
::WhitespaceCharacters
->throw();
745 elsif ( $error eq 'too_weak' ) {
746 Koha
::Exceptions
::Password
::TooWeak
->throw();
751 if ( C4
::Context
->preference('UseKohaPlugins') && C4
::Context
->config("enable_plugins") ) {
752 # Call any check_password plugins
753 my @plugins = Koha
::Plugins
->new()->GetPlugins({
754 method
=> 'check_password',
756 foreach my $plugin ( @plugins ) {
757 # This plugin hook will also be used by a plugin for the Norwegian national
758 # patron database. This is why we need to pass both the password and the
759 # borrowernumber to the plugin.
760 my $ret = $plugin->check_password(
762 password
=> $password,
763 borrowernumber
=> $self->borrowernumber
766 # This plugin hook will also be used by a plugin for the Norwegian national
767 # patron database. This is why we need to call the actual plugins and then
768 # check skip_validation afterwards.
769 if ( $ret->{'error'} == 1 && !$args->{skip_validation
} ) {
770 Koha
::Exceptions
::Password
::Plugin
->throw();
775 my $digest = Koha
::AuthUtils
::hash_password
($password);
777 # We do not want to call $self->store and retrieve password from DB
778 $self->password($digest);
779 $self->login_attempts(0);
782 logaction
( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" )
783 if C4
::Context
->preference("BorrowersLog");
791 my $new_expiry_date = $patron->renew_account
793 Extending the subscription to the expiry date.
800 if ( C4
::Context
->preference('BorrowerRenewalPeriodBase') eq 'combination' ) {
801 $date = ( dt_from_string
gt dt_from_string
( $self->dateexpiry ) ) ? dt_from_string
: dt_from_string
( $self->dateexpiry );
804 C4
::Context
->preference('BorrowerRenewalPeriodBase') eq 'dateexpiry'
805 ? dt_from_string
( $self->dateexpiry )
808 my $expiry_date = $self->category->get_expiry_date($date);
810 $self->dateexpiry($expiry_date);
811 $self->date_renewed( dt_from_string
() );
814 $self->add_enrolment_fee_if_needed(1);
816 logaction
( "MEMBERS", "RENEW", $self->borrowernumber, "Membership renewed" ) if C4
::Context
->preference("BorrowersLog");
817 return dt_from_string
( $expiry_date )->truncate( to
=> 'day' );
822 my $has_overdues = $patron->has_overdues;
824 Returns the number of patron's overdues
830 my $dtf = Koha
::Database
->new->schema->storage->datetime_parser;
831 return $self->_result->issues->search({ date_due
=> { '<' => $dtf->format_datetime( dt_from_string
() ) } })->count;
836 $patron->track_login;
837 $patron->track_login({ force => 1 });
839 Tracks a (successful) login attempt.
840 The preference TrackLastPatronActivity must be enabled. Or you
841 should pass the force parameter.
846 my ( $self, $params ) = @_;
849 !C4
::Context
->preference('TrackLastPatronActivity');
850 $self->lastseen( dt_from_string
() )->store;
853 =head3 move_to_deleted
855 my $is_moved = $patron->move_to_deleted;
857 Move a patron to the deletedborrowers table.
858 This can be done before deleting a patron, to make sure the data are not completely deleted.
862 sub move_to_deleted
{
864 my $patron_infos = $self->unblessed;
865 delete $patron_infos->{updated_on
}; #This ensures the updated_on date in deletedborrowers will be set to the current timestamp
866 return Koha
::Database
->new->schema->resultset('Deletedborrower')->create($patron_infos);
869 =head3 article_requests
871 my @requests = $borrower->article_requests();
872 my $requests = $borrower->article_requests();
874 Returns either a list of ArticleRequests objects,
875 or an ArtitleRequests object, depending on the
880 sub article_requests
{
883 $self->{_article_requests
} ||= Koha
::ArticleRequests
->search({ borrowernumber
=> $self->borrowernumber() });
885 return $self->{_article_requests
};
888 =head3 article_requests_current
890 my @requests = $patron->article_requests_current
892 Returns the article requests associated with this patron that are incomplete
896 sub article_requests_current
{
899 $self->{_article_requests_current
} ||= Koha
::ArticleRequests
->search(
901 borrowernumber
=> $self->id(),
903 { status
=> Koha
::ArticleRequest
::Status
::Pending
},
904 { status
=> Koha
::ArticleRequest
::Status
::Processing
}
909 return $self->{_article_requests_current
};
912 =head3 article_requests_finished
914 my @requests = $biblio->article_requests_finished
916 Returns the article requests associated with this patron that are completed
920 sub article_requests_finished
{
921 my ( $self, $borrower ) = @_;
923 $self->{_article_requests_finished
} ||= Koha
::ArticleRequests
->search(
925 borrowernumber
=> $self->id(),
927 { status
=> Koha
::ArticleRequest
::Status
::Completed
},
928 { status
=> Koha
::ArticleRequest
::Status
::Canceled
}
933 return $self->{_article_requests_finished
};
936 =head3 add_enrolment_fee_if_needed
938 my $enrolment_fee = $patron->add_enrolment_fee_if_needed($renewal);
940 Add enrolment fee for a patron if needed.
942 $renewal - boolean denoting whether this is an account renewal or not
946 sub add_enrolment_fee_if_needed
{
947 my ($self, $renewal) = @_;
948 my $enrolment_fee = $self->category->enrolmentfee;
949 if ( $enrolment_fee && $enrolment_fee > 0 ) {
950 my $type = $renewal ?
'ACCOUNT_RENEW' : 'ACCOUNT';
951 $self->account->add_debit(
953 amount
=> $enrolment_fee,
954 user_id
=> C4
::Context
->userenv ? C4
::Context
->userenv->{'number'} : undef,
955 interface
=> C4
::Context
->interface,
956 library_id
=> C4
::Context
->userenv ? C4
::Context
->userenv->{'branch'} : undef,
961 return $enrolment_fee || 0;
966 my $checkouts = $patron->checkouts
972 my $checkouts = $self->_result->issues;
973 return Koha
::Checkouts
->_new_from_dbic( $checkouts );
976 =head3 pending_checkouts
978 my $pending_checkouts = $patron->pending_checkouts
980 This method will return the same as $self->checkouts, but with a prefetch on
981 items, biblio and biblioitems.
983 It has been introduced to replaced the C4::Members::GetPendingIssues subroutine
985 It should not be used directly, prefer to access fields you need instead of
986 retrieving all these fields in one go.
990 sub pending_checkouts
{
992 my $checkouts = $self->_result->issues->search(
996 { -desc
=> 'me.timestamp' },
997 { -desc
=> 'issuedate' },
998 { -desc
=> 'issue_id' }, # Sort by issue_id should be enough
1000 prefetch
=> { item
=> { biblio
=> 'biblioitems' } },
1003 return Koha
::Checkouts
->_new_from_dbic( $checkouts );
1006 =head3 old_checkouts
1008 my $old_checkouts = $patron->old_checkouts
1014 my $old_checkouts = $self->_result->old_issues;
1015 return Koha
::Old
::Checkouts
->_new_from_dbic( $old_checkouts );
1020 my $overdue_items = $patron->get_overdues
1022 Return the overdue items
1028 my $dtf = Koha
::Database
->new->schema->storage->datetime_parser;
1029 return $self->checkouts->search(
1031 'me.date_due' => { '<' => $dtf->format_datetime(dt_from_string
) },
1034 prefetch
=> { item
=> { biblio
=> 'biblioitems' } },
1039 =head3 get_routing_lists
1041 my @routinglists = $patron->get_routing_lists
1043 Returns the routing lists a patron is subscribed to.
1047 sub get_routing_lists
{
1049 my $routing_list_rs = $self->_result->subscriptionroutinglists;
1050 return Koha
::Subscription
::Routinglists
->_new_from_dbic($routing_list_rs);
1055 my $age = $patron->get_age
1057 Return the age of the patron
1063 my $today_str = dt_from_string
->strftime("%Y-%m-%d");
1064 return unless $self->dateofbirth;
1065 my $dob_str = dt_from_string
( $self->dateofbirth )->strftime("%Y-%m-%d");
1067 my ( $dob_y, $dob_m, $dob_d ) = split /-/, $dob_str;
1068 my ( $today_y, $today_m, $today_d ) = split /-/, $today_str;
1070 my $age = $today_y - $dob_y;
1071 if ( $dob_m . $dob_d > $today_m . $today_d ) {
1080 my $is_valid = $patron->is_valid_age
1082 Return 1 if patron's age is between allowed limits, returns 0 if it's not.
1088 my $age = $self->get_age;
1090 my $patroncategory = $self->category;
1091 my ($low,$high) = ($patroncategory->dateofbirthrequired, $patroncategory->upperagelimit);
1093 return (defined($age) && (($high && ($age > $high)) or ($age < $low))) ?
0 : 1;
1098 my $account = $patron->account
1104 return Koha
::Account
->new( { patron_id
=> $self->borrowernumber } );
1109 my $holds = $patron->holds
1111 Return all the holds placed by this patron
1117 my $holds_rs = $self->_result->reserves->search( {}, { order_by
=> 'reservedate' } );
1118 return Koha
::Holds
->_new_from_dbic($holds_rs);
1123 my $old_holds = $patron->old_holds
1125 Return all the historical holds for this patron
1131 my $old_holds_rs = $self->_result->old_reserves->search( {}, { order_by
=> 'reservedate' } );
1132 return Koha
::Old
::Holds
->_new_from_dbic($old_holds_rs);
1135 =head3 return_claims
1137 my $return_claims = $patron->return_claims
1143 my $return_claims = $self->_result->return_claims_borrowernumbers;
1144 return Koha
::Checkouts
::ReturnClaims
->_new_from_dbic( $return_claims );
1147 =head3 notice_email_address
1149 my $email = $patron->notice_email_address;
1151 Return the email address of patron used for notices.
1152 Returns the empty string if no email address.
1156 sub notice_email_address
{
1159 my $which_address = C4
::Context
->preference("AutoEmailPrimaryAddress");
1160 # if syspref is set to 'first valid' (value == OFF), look up email address
1161 if ( $which_address eq 'OFF' ) {
1162 return $self->first_valid_email_address;
1165 return $self->$which_address || '';
1168 =head3 first_valid_email_address
1170 my $first_valid_email_address = $patron->first_valid_email_address
1172 Return the first valid email address for a patron.
1173 For now, the order is defined as email, emailpro, B_email.
1174 Returns the empty string if the borrower has no email addresses.
1178 sub first_valid_email_address
{
1181 return $self->email() || $self->emailpro() || $self->B_email() || q{};
1184 =head3 get_club_enrollments
1188 sub get_club_enrollments
{
1189 my ( $self, $return_scalar ) = @_;
1191 my $e = Koha
::Club
::Enrollments
->search( { borrowernumber
=> $self->borrowernumber(), date_canceled
=> undef } );
1193 return $e if $return_scalar;
1195 return wantarray ?
$e->as_list : $e;
1198 =head3 get_enrollable_clubs
1202 sub get_enrollable_clubs
{
1203 my ( $self, $is_enrollable_from_opac, $return_scalar ) = @_;
1206 $params->{is_enrollable_from_opac
} = $is_enrollable_from_opac
1207 if $is_enrollable_from_opac;
1208 $params->{is_email_required
} = 0 unless $self->first_valid_email_address();
1210 $params->{borrower
} = $self;
1212 my $e = Koha
::Clubs
->get_enrollable($params);
1214 return $e if $return_scalar;
1216 return wantarray ?
$e->as_list : $e;
1219 =head3 account_locked
1221 my $is_locked = $patron->account_locked
1223 Return true if the patron has reached the maximum number of login attempts
1224 (see pref FailedLoginAttempts). If login_attempts is < 0, this is interpreted
1225 as an administrative lockout (independent of FailedLoginAttempts; see also
1226 Koha::Patron->lock).
1227 Otherwise return false.
1228 If the pref is not set (empty string, null or 0), the feature is considered as
1233 sub account_locked
{
1235 my $FailedLoginAttempts = C4
::Context
->preference('FailedLoginAttempts');
1236 return 1 if $FailedLoginAttempts
1237 and $self->login_attempts
1238 and $self->login_attempts >= $FailedLoginAttempts;
1239 return 1 if ($self->login_attempts || 0) < 0; # administrative lockout
1243 =head3 can_see_patron_infos
1245 my $can_see = $patron->can_see_patron_infos( $patron );
1247 Return true if the patron (usually the logged in user) can see the patron's infos for a given patron
1251 sub can_see_patron_infos
{
1252 my ( $self, $patron ) = @_;
1253 return unless $patron;
1254 return $self->can_see_patrons_from( $patron->library->branchcode );
1257 =head3 can_see_patrons_from
1259 my $can_see = $patron->can_see_patrons_from( $branchcode );
1261 Return true if the patron (usually the logged in user) can see the patron's infos from a given library
1265 sub can_see_patrons_from
{
1266 my ( $self, $branchcode ) = @_;
1268 if ( $self->branchcode eq $branchcode ) {
1270 } elsif ( $self->has_permission( { borrowers
=> 'view_borrower_infos_from_any_libraries' } ) ) {
1272 } elsif ( my $library_groups = $self->library->library_groups ) {
1273 while ( my $library_group = $library_groups->next ) {
1274 if ( $library_group->parent->has_child( $branchcode ) ) {
1283 =head3 libraries_where_can_see_patrons
1285 my $libraries = $patron-libraries_where_can_see_patrons;
1287 Return the list of branchcodes(!) of libraries the patron is allowed to see other patron's infos.
1288 The branchcodes are arbitrarily returned sorted.
1289 We are supposing here that the object is related to the logged in patron (use of C4::Context::only_my_library)
1291 An empty array means no restriction, the patron can see patron's infos from any libraries.
1295 sub libraries_where_can_see_patrons
{
1297 my $userenv = C4
::Context
->userenv;
1299 return () unless $userenv; # For tests, but userenv should be defined in tests...
1301 my @restricted_branchcodes;
1302 if (C4
::Context
::only_my_library
) {
1303 push @restricted_branchcodes, $self->branchcode;
1307 $self->has_permission(
1308 { borrowers
=> 'view_borrower_infos_from_any_libraries' }
1312 my $library_groups = $self->library->library_groups({ ft_hide_patron_info
=> 1 });
1313 if ( $library_groups->count )
1315 while ( my $library_group = $library_groups->next ) {
1316 my $parent = $library_group->parent;
1317 if ( $parent->has_child( $self->branchcode ) ) {
1318 push @restricted_branchcodes, $parent->children->get_column('branchcode');
1323 @restricted_branchcodes = ( $self->branchcode ) unless @restricted_branchcodes;
1327 @restricted_branchcodes = grep { defined $_ } @restricted_branchcodes;
1328 @restricted_branchcodes = uniq
(@restricted_branchcodes);
1329 @restricted_branchcodes = sort(@restricted_branchcodes);
1330 return @restricted_branchcodes;
1333 sub has_permission
{
1334 my ( $self, $flagsrequired ) = @_;
1335 return unless $self->userid;
1336 # TODO code from haspermission needs to be moved here!
1337 return C4
::Auth
::haspermission
( $self->userid, $flagsrequired );
1342 my $is_adult = $patron->is_adult
1344 Return true if the patron has a category with a type Adult (A) or Organization (I)
1350 return $self->category->category_type =~ /^(A|I)$/ ?
1 : 0;
1355 my $is_child = $patron->is_child
1357 Return true if the patron has a category with a type Child (C)
1363 return $self->category->category_type eq 'C' ?
1 : 0;
1366 =head3 has_valid_userid
1368 my $patron = Koha::Patrons->find(42);
1369 $patron->userid( $new_userid );
1370 my $has_a_valid_userid = $patron->has_valid_userid
1372 my $patron = Koha::Patron->new( $params );
1373 my $has_a_valid_userid = $patron->has_valid_userid
1375 Return true if the current userid of this patron is valid/unique, otherwise false.
1377 Note that this should be done in $self->store instead and raise an exception if needed.
1381 sub has_valid_userid
{
1384 return 0 unless $self->userid;
1386 return 0 if ( $self->userid eq C4
::Context
->config('user') ); # DB user
1388 my $already_exists = Koha
::Patrons
->search(
1390 userid
=> $self->userid,
1393 ?
( borrowernumber
=> { '!=' => $self->borrowernumber } )
1398 return $already_exists ?
0 : 1;
1401 =head3 generate_userid
1403 my $patron = Koha::Patron->new( $params );
1404 $patron->generate_userid
1406 Generate a userid using the $surname and the $firstname (if there is a value in $firstname).
1408 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).
1412 sub generate_userid
{
1415 my $firstname = $self->firstname // q{};
1416 my $surname = $self->surname // q{};
1417 #The script will "do" the following code and increment the $offset until the generated userid is unique
1419 $firstname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1420 $surname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1421 my $userid = lc(($firstname)?
"$firstname.$surname" : $surname);
1422 $userid = NFKD
( $userid );
1423 $userid =~ s/\p{NonspacingMark}//g;
1424 $userid .= $offset unless $offset == 0;
1425 $self->userid( $userid );
1427 } while (! $self->has_valid_userid );
1434 my $attributes = $patron->attributes
1436 Return object of Koha::Patron::Attributes type with all attributes set for this patron
1442 return Koha
::Patron
::Attributes
->search({
1443 borrowernumber
=> $self->borrowernumber,
1444 branchcode
=> $self->branchcode,
1450 Koha::Patrons->find($id)->lock({ expire => 1, remove => 1 });
1452 Lock and optionally expire a patron account.
1453 Remove holds and article requests if remove flag set.
1454 In order to distinguish from locking by entering a wrong password, let's
1455 call this an administrative lockout.
1460 my ( $self, $params ) = @_;
1461 $self->login_attempts( ADMINISTRATIVE_LOCKOUT
);
1462 if( $params->{expire
} ) {
1463 $self->dateexpiry( dt_from_string
->subtract(days
=> 1) );
1466 if( $params->{remove
} ) {
1467 $self->holds->delete;
1468 $self->article_requests->delete;
1475 Koha::Patrons->find($id)->anonymize;
1477 Anonymize or clear borrower fields. Fields in BorrowerMandatoryField
1478 are randomized, other personal data is cleared too.
1479 Patrons with issues are skipped.
1485 if( $self->_result->issues->count ) {
1486 warn "Exiting anonymize: patron ".$self->borrowernumber." still has issues";
1489 # Mandatory fields come from the corresponding pref, but email fields
1490 # are removed since scrambled email addresses only generate errors
1491 my $mandatory = { map { (lc $_, 1); } grep { !/email/ }
1492 split /\s*\|\s*/, C4
::Context
->preference('BorrowerMandatoryField') };
1493 $mandatory->{userid
} = 1; # needed since sub store does not clear field
1494 my @columns = $self->_result->result_source->columns;
1495 @columns = grep { !/borrowernumber|branchcode|categorycode|^date|password|flags|updated_on|lastseen|lang|login_attempts|anonymized/ } @columns;
1496 push @columns, 'dateofbirth'; # add this date back in
1497 foreach my $col (@columns) {
1498 $self->_anonymize_column($col, $mandatory->{lc $col} );
1500 $self->anonymized(1)->store;
1503 sub _anonymize_column
{
1504 my ( $self, $col, $mandatory ) = @_;
1505 my $col_info = $self->_result->result_source->column_info($col);
1506 my $type = $col_info->{data_type
};
1507 my $nullable = $col_info->{is_nullable
};
1509 if( $type =~ /char|text/ ) {
1511 ? Koha
::Token
->new->generate({ pattern
=> '\w{10}' })
1515 } elsif( $type =~ /integer|int$|float|dec|double/ ) {
1516 $val = $nullable ?
undef : 0;
1517 } elsif( $type =~ /date|time/ ) {
1518 $val = $nullable ?
undef : dt_from_string
;
1523 =head3 add_guarantor
1525 my @relationships = $patron->add_guarantor(
1527 borrowernumber => $borrowernumber,
1528 relationships => $relationship,
1532 Adds a new guarantor to a patron.
1537 my ( $self, $params ) = @_;
1539 my $guarantor_id = $params->{guarantor_id
};
1540 my $relationship = $params->{relationship
};
1542 return Koha
::Patron
::Relationship
->new(
1544 guarantee_id
=> $self->id,
1545 guarantor_id
=> $guarantor_id,
1546 relationship
=> $relationship
1553 my $json = $patron->to_api;
1555 Overloaded method that returns a JSON representation of the Koha::Patron object,
1556 suitable for API output.
1563 my $json_patron = $self->SUPER::to_api
;
1565 $json_patron->{restricted
} = ( $self->is_debarred )
1567 : Mojo
::JSON
->false;
1569 return $json_patron;
1572 =head3 to_api_mapping
1574 This method returns the mapping for representing a Koha::Patron object
1579 sub to_api_mapping
{
1581 borrowernotes
=> 'staff_notes',
1582 borrowernumber
=> 'patron_id',
1583 branchcode
=> 'library_id',
1584 categorycode
=> 'category_id',
1585 checkprevcheckout
=> 'check_previous_checkout',
1586 contactfirstname
=> undef, # Unused
1587 contactname
=> undef, # Unused
1588 contactnote
=> 'altaddress_notes',
1589 contacttitle
=> undef, # Unused
1590 dateenrolled
=> 'date_enrolled',
1591 dateexpiry
=> 'expiry_date',
1592 dateofbirth
=> 'date_of_birth',
1593 debarred
=> undef, # replaced by 'restricted'
1594 debarredcomment
=> undef, # calculated, API consumers will use /restrictions instead
1595 emailpro
=> 'secondary_email',
1596 flags
=> undef, # permissions manipulation handled in /permissions
1597 gonenoaddress
=> 'incorrect_address',
1598 guarantorid
=> 'guarantor_id',
1599 lastseen
=> 'last_seen',
1600 lost
=> 'patron_card_lost',
1601 opacnote
=> 'opac_notes',
1602 othernames
=> 'other_name',
1603 password
=> undef, # password manipulation handled in /password
1604 phonepro
=> 'secondary_phone',
1605 relationship
=> 'relationship_type',
1607 smsalertnumber
=> 'sms_number',
1608 sort1
=> 'statistics_1',
1609 sort2
=> 'statistics_2',
1610 streetnumber
=> 'street_number',
1611 streettype
=> 'street_type',
1612 zipcode
=> 'postal_code',
1613 B_address
=> 'altaddress_address',
1614 B_address2
=> 'altaddress_address2',
1615 B_city
=> 'altaddress_city',
1616 B_country
=> 'altaddress_country',
1617 B_email
=> 'altaddress_email',
1618 B_phone
=> 'altaddress_phone',
1619 B_state
=> 'altaddress_state',
1620 B_streetnumber
=> 'altaddress_street_number',
1621 B_streettype
=> 'altaddress_street_type',
1622 B_zipcode
=> 'altaddress_postal_code',
1623 altcontactaddress1
=> 'altcontact_address',
1624 altcontactaddress2
=> 'altcontact_address2',
1625 altcontactaddress3
=> 'altcontact_city',
1626 altcontactcountry
=> 'altcontact_country',
1627 altcontactfirstname
=> 'altcontact_firstname',
1628 altcontactphone
=> 'altcontact_phone',
1629 altcontactsurname
=> 'altcontact_surname',
1630 altcontactstate
=> 'altcontact_state',
1631 altcontactzipcode
=> 'altcontact_postal_code'
1635 =head2 Internal methods
1647 Kyle M Hall <kyle@bywatersolutions.com>
1648 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
1649 Martin Renvoize <martin.renvoize@ptfs-europe.com>