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
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.
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.
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>.
23 use List::MoreUtils qw( any uniq );
24 use JSON qw( to_json );
25 use Unicode::Normalize qw( NFKD );
29 use C4::Auth qw( checkpw_hash );
30 use C4::Log qw( logaction );
33 use Koha::ArticleRequests;
34 use C4::Letters qw( GetPreparedLetter EnqueueLetter SendQueuedMessages );
37 use Koha::CirculationRules;
38 use Koha::Club::Enrollments;
40 use Koha::DateUtils qw( dt_from_string );
42 use Koha::Exceptions::Password;
44 use Koha::CurbsidePickups;
45 use Koha::Old::Checkouts;
46 use Koha::Patron::Attributes;
47 use Koha::Patron::Categories;
48 use Koha::Patron::Debarments;
49 use Koha::Patron::HouseboundProfile;
50 use Koha::Patron::HouseboundRole;
51 use Koha::Patron::Images;
52 use Koha::Patron::Messages;
53 use Koha::Patron::Modifications;
54 use Koha::Patron::Relationships;
55 use Koha::Patron::Restrictions;
59 use Koha::Result::Boolean;
60 use Koha::Subscription::Routinglists;
62 use Koha::Virtualshelves;
64 use base qw(Koha::Object);
66 use constant ADMINISTRATIVE_LOCKOUT => -1;
68 our $RESULTSET_PATRON_ID_MAPPING = {
69 Accountline => 'borrowernumber',
70 Aqbasketuser => 'borrowernumber',
71 Aqbudget => 'budget_owner_id',
72 Aqbudgetborrower => 'borrowernumber',
73 ArticleRequest => 'borrowernumber',
74 BorrowerDebarment => 'borrowernumber',
75 BorrowerFile => 'borrowernumber',
76 BorrowerModification => 'borrowernumber',
77 ClubEnrollment => 'borrowernumber',
78 Issue => 'borrowernumber',
79 ItemsLastBorrower => 'borrowernumber',
80 Linktracker => 'borrowernumber',
81 Message => 'borrowernumber',
82 MessageQueue => 'borrowernumber',
83 OldIssue => 'borrowernumber',
84 OldReserve => 'borrowernumber',
85 Rating => 'borrowernumber',
86 Reserve => 'borrowernumber',
87 Review => 'borrowernumber',
88 SearchHistory => 'userid',
89 Statistic => 'borrowernumber',
90 Suggestion => 'suggestedby',
91 TagAll => 'borrowernumber',
92 Virtualshelfcontent => 'borrowernumber',
93 Virtualshelfshare => 'borrowernumber',
94 Virtualshelve => 'owner',
99 Koha::Patron - Koha Patron Object class
110 my ( $class, $params ) = @_;
112 return $class->SUPER::new($params);
115 =head3 fixup_cardnumber
117 Autogenerate next cardnumber from highest value found in database
121 sub fixup_cardnumber {
124 my $max = $self->cardnumber;
125 Koha::Plugins->call( 'patron_barcode_transform', \$max );
127 $max ||= Koha::Patrons->search({
128 cardnumber => {-regexp => '^-?[0-9]+$'}
130 select => \'CAST(cardnumber AS SIGNED)',
131 as => ['cast_cardnumber']
132 })->_resultset->get_column('cast_cardnumber')->max;
133 $self->cardnumber(($max || 0) +1);
136 =head3 trim_whitespace
138 trim whitespace from data which has some non-whitespace in it.
139 Could be moved to Koha::Object if need to be reused
143 sub trim_whitespaces {
146 my $schema = Koha::Database->new->schema;
147 my @columns = $schema->source($self->_type)->columns;
149 for my $column( @columns ) {
150 my $value = $self->$column;
151 if ( defined $value ) {
152 $value =~ s/^\s*|\s*$//g;
153 $self->$column($value);
159 =head3 plain_text_password
161 $patron->plain_text_password( $password );
163 stores a copy of the unencrypted password in the object
164 for use in code before encrypting for db
168 sub plain_text_password {
169 my ( $self, $password ) = @_;
171 $self->{_plain_text_password} = $password;
174 return $self->{_plain_text_password}
175 if $self->{_plain_text_password};
182 Patron specific store method to cleanup record
183 and do other necessary things before saving
191 $self->_result->result_source->schema->txn_do(
194 C4::Context->preference("autoMemberNum")
195 and ( not defined $self->cardnumber
196 or $self->cardnumber eq '' )
199 # Warning: The caller is responsible for locking the members table in write
200 # mode, to avoid database corruption.
201 # We are in a transaction but the table is not locked
202 $self->fixup_cardnumber;
205 unless( $self->category->in_storage ) {
206 Koha::Exceptions::Object::FKConstraint->throw(
207 broken_fk => 'categorycode',
208 value => $self->categorycode,
212 $self->trim_whitespaces;
214 my $new_cardnumber = $self->cardnumber;
215 Koha::Plugins->call( 'patron_barcode_transform', \$new_cardnumber );
216 $self->cardnumber( $new_cardnumber );
218 # Set surname to uppercase if uppercasesurname is true
219 $self->surname( uc($self->surname) )
220 if C4::Context->preference("uppercasesurnames");
222 $self->relationship(undef) # We do not want to store an empty string in this field
223 if defined $self->relationship
224 and $self->relationship eq "";
226 for my $note_field (qw( borrowernotes opacnote )) {
227 if ( !$self->in_storage || $self->_result->is_column_changed($note_field) ) {
228 $self->$note_field( C4::Scrubber->new('note')->scrub( $self->$note_field ) );
232 unless ( $self->in_storage ) { #AddMember
234 # Generate a valid userid/login if needed
235 $self->generate_userid unless $self->userid;
236 Koha::Exceptions::Patron::InvalidUserid->throw( userid => $self->userid )
237 unless $self->has_valid_userid;
239 # Add expiration date if it isn't already there
240 unless ( $self->dateexpiry ) {
241 $self->dateexpiry( $self->category->get_expiry_date );
244 # Add enrollment date if it isn't already there
245 unless ( $self->dateenrolled ) {
246 $self->dateenrolled(dt_from_string);
249 # Set the privacy depending on the patron's category
250 my $default_privacy = $self->category->default_privacy || q{};
252 $default_privacy eq 'default' ? 1
253 : $default_privacy eq 'never' ? 2
254 : $default_privacy eq 'forever' ? 0
256 $self->privacy($default_privacy);
258 # Call any check_password plugins if password is passed
259 if ( C4::Context->config("enable_plugins") && $self->password ) {
260 my @plugins = Koha::Plugins->new()->GetPlugins({
261 method => 'check_password',
263 foreach my $plugin ( @plugins ) {
264 # This plugin hook will also be used by a plugin for the Norwegian national
265 # patron database. This is why we need to pass both the password and the
266 # borrowernumber to the plugin.
267 my $ret = $plugin->check_password(
269 password => $self->password,
270 borrowernumber => $self->borrowernumber
273 if ( $ret->{'error'} == 1 ) {
274 Koha::Exceptions::Password::Plugin->throw();
279 # Make a copy of the plain text password for later use
280 $self->plain_text_password( $self->password );
282 $self->password_expiration_date( $self->password
283 ? $self->category->get_password_expiry_date || undef
285 # Create a disabled account if no password provided
286 $self->password( $self->password
287 ? Koha::AuthUtils::hash_password( $self->password )
290 $self->borrowernumber(undef);
292 $self = $self->SUPER::store;
294 $self->add_enrolment_fee_if_needed(0);
296 logaction( "MEMBERS", "CREATE", $self->borrowernumber, "" )
297 if C4::Context->preference("BorrowersLog");
301 my $self_from_storage = $self->get_from_storage;
303 # Do not accept invalid userid here
304 $self->generate_userid unless $self->userid;
305 Koha::Exceptions::Patron::InvalidUserid->throw( userid => $self->userid )
306 unless $self->has_valid_userid;
308 # If a borrower has set their privacy to never we should immediately anonymize
310 if( $self->privacy() == 2 && $self_from_storage->privacy() != 2 ){
312 $self->old_checkouts->anonymize;
315 Koha::Exceptions::Patron::FailedAnonymizing->throw(
321 # Password must be updated using $self->set_password
322 $self->password($self_from_storage->password);
324 if ( $self->category->categorycode ne
325 $self_from_storage->category->categorycode )
327 # Add enrolement fee on category change if required
328 $self->add_enrolment_fee_if_needed(1)
329 if C4::Context->preference('FeeOnChangePatronCategory');
331 # Clean up guarantors on category change if required
332 $self->guarantor_relationships->delete
333 unless ( $self->category->can_be_guarantee );
338 if ( C4::Context->preference("BorrowersLog") ) {
340 my $from_storage = $self_from_storage->unblessed;
341 my $from_object = $self->unblessed;
342 my @skip_fields = (qw/lastseen updated_on/);
343 for my $key ( keys %{$from_storage} ) {
344 next if any { /$key/ } @skip_fields;
347 !defined( $from_storage->{$key} )
348 && defined( $from_object->{$key} )
350 || ( defined( $from_storage->{$key} )
351 && !defined( $from_object->{$key} ) )
353 defined( $from_storage->{$key} )
354 && defined( $from_object->{$key} )
355 && ( $from_storage->{$key} ne
356 $from_object->{$key} )
361 before => $from_storage->{$key},
362 after => $from_object->{$key}
367 if ( defined($info) ) {
371 $self->borrowernumber,
374 { utf8 => 1, pretty => 1, canonical => 1 }
381 $self = $self->SUPER::store;
392 Delete patron's holds, lists and finally the patron.
394 Lists owned by the borrower are deleted or ownership is transferred depending on the
395 ListOwnershipUponPatronDeletion pref, but entries from the borrower to other lists are kept.
402 my $anonymous_patron = C4::Context->preference("AnonymousPatron");
403 Koha::Exceptions::Patron::FailedDeleteAnonymousPatron->throw() if $anonymous_patron && $self->id eq $anonymous_patron;
405 $self->_result->result_source->schema->txn_do(
407 # Cancel Patron's holds
408 my $holds = $self->holds;
409 while( my $hold = $holds->next ){
413 # Handle lists (virtualshelves)
414 $self->virtualshelves->disown_or_delete;
416 # We cannot have a FK on borrower_modifications.borrowernumber, the table is also used
418 $_->delete for Koha::Patron::Modifications->search( { borrowernumber => $self->borrowernumber } )->as_list;
420 $self->SUPER::delete;
422 logaction( "MEMBERS", "DELETE", $self->borrowernumber, "" ) if C4::Context->preference("BorrowersLog");
430 my $patron_category = $patron->category
432 Return the patron category for this patron
438 return Koha::Patron::Category->_new_from_dbic( $self->_result->categorycode );
448 return Koha::Patron::Images->find( $self->borrowernumber );
453 Returns a Koha::Library object representing the patron's home library.
459 return Koha::Library->_new_from_dbic($self->_result->branchcode);
464 Returns a Koha::SMS::Provider object representing the patron's SMS provider.
470 my $sms_provider_rs = $self->_result->sms_provider;
471 return unless $sms_provider_rs;
472 return Koha::SMS::Provider->_new_from_dbic($sms_provider_rs);
475 =head3 guarantor_relationships
477 Returns Koha::Patron::Relationships object for this patron's guarantors
479 Returns the set of relationships for the patrons that are guarantors for this patron.
481 Note that a guarantor should exist as a patron in Koha; it was not possible
482 to add them without a guarantor_id in the interface for some time. Bug 30472
483 restricts it on db level.
487 sub guarantor_relationships {
490 return Koha::Patron::Relationships->search( { guarantee_id => $self->id } );
493 =head3 guarantee_relationships
495 Returns Koha::Patron::Relationships object for this patron's guarantors
497 Returns the set of relationships for the patrons that are guarantees for this patron.
499 The method returns Koha::Patron::Relationship objects for the sake
500 of consistency with the guantors method.
501 A guarantee by definition must exist as a patron in Koha.
505 sub guarantee_relationships {
508 return Koha::Patron::Relationships->search(
509 { guarantor_id => $self->id },
511 prefetch => 'guarantee',
512 order_by => { -asc => [ 'guarantee.surname', 'guarantee.firstname' ] },
517 =head3 relationships_debt
519 Returns the amount owed by the patron's guarantors *and* the other guarantees of those guarantors
523 sub relationships_debt {
524 my ($self, $params) = @_;
526 my $include_guarantors = $params->{include_guarantors};
527 my $only_this_guarantor = $params->{only_this_guarantor};
528 my $include_this_patron = $params->{include_this_patron};
531 if ( $only_this_guarantor ) {
532 @guarantors = $self->guarantee_relationships->count ? ( $self ) : ();
533 Koha::Exceptions::BadParameter->throw( { parameter => 'only_this_guarantor' } ) unless @guarantors;
534 } elsif ( $self->guarantor_relationships->count ) {
535 # I am a guarantee, just get all my guarantors
536 @guarantors = $self->guarantor_relationships->guarantors->as_list;
538 # I am a guarantor, I need to get all the guarantors of all my guarantees
539 @guarantors = map { $_->guarantor_relationships->guarantors->as_list } $self->guarantee_relationships->guarantees->as_list;
542 my $non_issues_charges = 0;
543 my $seen = $include_this_patron ? {} : { $self->id => 1 }; # For tracking members already added to the total
544 foreach my $guarantor (@guarantors) {
545 $non_issues_charges += $guarantor->account->non_issues_charges if $include_guarantors && !$seen->{ $guarantor->id };
547 # We've added what the guarantor owes, not added in that guarantor's guarantees as well
548 my @guarantees = map { $_->guarantee } $guarantor->guarantee_relationships->as_list;
549 my $guarantees_non_issues_charges = 0;
550 foreach my $guarantee (@guarantees) {
551 next if $seen->{ $guarantee->id };
552 $guarantees_non_issues_charges += $guarantee->account->non_issues_charges;
553 # Mark this guarantee as seen so we don't double count a guarantee linked to multiple guarantors
554 $seen->{ $guarantee->id } = 1;
557 $non_issues_charges += $guarantees_non_issues_charges;
558 $seen->{ $guarantor->id } = 1;
561 return $non_issues_charges;
564 =head3 housebound_profile
566 Returns the HouseboundProfile associated with this patron.
570 sub housebound_profile {
572 my $profile = $self->_result->housebound_profile;
573 return Koha::Patron::HouseboundProfile->_new_from_dbic($profile)
578 =head3 housebound_role
580 Returns the HouseboundRole associated with this patron.
584 sub housebound_role {
587 my $role = $self->_result->housebound_role;
588 return Koha::Patron::HouseboundRole->_new_from_dbic($role) if ( $role );
594 Returns the siblings of this patron.
601 my @guarantors = $self->guarantor_relationships()->guarantors()->as_list;
603 return unless @guarantors;
606 map { $_->guarantee_relationships()->guarantees()->as_list } @guarantors;
608 return unless @siblings;
612 grep { !$seen{ $_->id }++ && ( $_->id != $self->id ) } @siblings;
614 return Koha::Patrons->search( { borrowernumber => { -in => [ map { $_->id } @siblings ] } } );
619 my $patron = Koha::Patrons->find($id);
620 $patron->merge_with( \@patron_ids );
622 This subroutine merges a list of patrons into the patron record. This is accomplished by finding
623 all related patron ids for the patrons to be merged in other tables and changing the ids to be that
624 of the keeper patron.
629 my ( $self, $patron_ids ) = @_;
631 my $anonymous_patron = C4::Context->preference("AnonymousPatron");
632 return if $anonymous_patron && $self->id eq $anonymous_patron;
634 my @patron_ids = @{ $patron_ids };
636 # Ensure the keeper isn't in the list of patrons to merge
637 @patron_ids = grep { $_ ne $self->id } @patron_ids;
639 my $schema = Koha::Database->new()->schema();
643 $self->_result->result_source->schema->txn_do( sub {
644 foreach my $patron_id (@patron_ids) {
646 next if $patron_id eq $anonymous_patron;
648 my $patron = Koha::Patrons->find( $patron_id );
652 # Unbless for safety, the patron will end up being deleted
653 $results->{merged}->{$patron_id}->{patron} = $patron->unblessed;
655 my $attributes = $patron->extended_attributes;
656 my $new_attributes = [
657 map { { code => $_->code, attribute => $_->attribute } }
660 $attributes->delete; # We need to delete before trying to merge them to prevent exception on unique and repeatable
661 for my $attribute ( @$new_attributes ) {
663 $self->add_extended_attribute($attribute);
665 # Don't block the merge if there is a non-repeatable attribute that cannot be added to the current patron.
666 unless ( $_->isa('Koha::Exceptions::Patron::Attribute::NonRepeatable') ) {
672 while (my ($r, $field) = each(%$RESULTSET_PATRON_ID_MAPPING)) {
673 my $rs = $schema->resultset($r)->search({ $field => $patron_id });
674 $results->{merged}->{ $patron_id }->{updated}->{$r} = $rs->count();
675 $rs->update({ $field => $self->id });
676 if ( $r eq 'BorrowerDebarment' ) {
677 Koha::Patron::Debarments::UpdateBorrowerDebarmentFlags($self->id);
681 $patron->move_to_deleted();
691 =head3 wants_check_for_previous_checkout
693 $wants_check = $patron->wants_check_for_previous_checkout;
695 Return 1 if Koha needs to perform PrevIssue checking, else 0.
699 sub wants_check_for_previous_checkout {
701 my $syspref = C4::Context->preference("checkPrevCheckout");
704 ## Hard syspref trumps all
705 return 1 if ($syspref eq 'hardyes');
706 return 0 if ($syspref eq 'hardno');
707 ## Now, patron pref trumps all
708 return 1 if ($self->checkprevcheckout eq 'yes');
709 return 0 if ($self->checkprevcheckout eq 'no');
711 # More complex: patron inherits -> determine category preference
712 my $checkPrevCheckoutByCat = $self->category->checkprevcheckout;
713 return 1 if ($checkPrevCheckoutByCat eq 'yes');
714 return 0 if ($checkPrevCheckoutByCat eq 'no');
716 # Finally: category preference is inherit, default to 0
717 if ($syspref eq 'softyes') {
724 =head3 do_check_for_previous_checkout
726 $do_check = $patron->do_check_for_previous_checkout($item);
728 Return 1 if the bib associated with $ITEM has previously been checked out to
729 $PATRON, 0 otherwise.
733 sub do_check_for_previous_checkout {
734 my ( $self, $item ) = @_;
737 my $biblio = Koha::Biblios->find( $item->{biblionumber} );
738 if ( $biblio->is_serial ) {
739 push @item_nos, $item->{itemnumber};
741 # Get all itemnumbers for given bibliographic record.
742 @item_nos = $biblio->items->get_column( 'itemnumber' );
745 # Create (old)issues search criteria
747 borrowernumber => $self->borrowernumber,
748 itemnumber => \@item_nos,
751 my $delay = C4::Context->preference('CheckPrevCheckoutDelay') || 0;
753 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
754 my $newer_than = dt_from_string()->subtract( days => $delay );
755 $criteria->{'returndate'} = { '>' => $dtf->format_datetime($newer_than), };
758 # Check current issues table
759 my $issues = Koha::Checkouts->search($criteria);
760 return 1 if $issues->count; # 0 || N
762 # Check old issues table
763 my $old_issues = Koha::Old::Checkouts->search($criteria);
764 return $old_issues->count; # 0 || N
769 my $debarment_expiration = $patron->is_debarred;
771 Returns the date a patron debarment will expire, or undef if the patron is not
779 return unless $self->debarred;
780 return $self->debarred
781 if $self->debarred =~ '^9999'
782 or dt_from_string( $self->debarred ) > dt_from_string;
788 my $is_expired = $patron->is_expired;
790 Returns 1 if the patron is expired or 0;
796 return 0 unless $self->dateexpiry;
797 return 0 if $self->dateexpiry =~ '^9999';
798 return 1 if dt_from_string( $self->dateexpiry ) < dt_from_string->truncate( to => 'day' );
802 =head3 password_expired
804 my $password_expired = $patron->password_expired;
806 Returns 1 if the patron's password is expired or 0;
810 sub password_expired {
812 return 0 unless $self->password_expiration_date;
813 return 1 if dt_from_string( $self->password_expiration_date ) <= dt_from_string->truncate( to => 'day' );
817 =head3 is_going_to_expire
819 my $is_going_to_expire = $patron->is_going_to_expire;
821 Returns 1 if the patron is going to expired, depending on the NotifyBorrowerDeparture pref or 0
825 sub is_going_to_expire {
828 my $delay = C4::Context->preference('NotifyBorrowerDeparture') || 0;
830 return 0 unless $delay;
831 return 0 unless $self->dateexpiry;
832 return 0 if $self->dateexpiry =~ '^9999';
833 return 1 if dt_from_string( $self->dateexpiry, undef, 'floating' )->subtract( days => $delay ) < dt_from_string(undef, undef, 'floating')->truncate( to => 'day' );
839 $patron->set_password({ password => $plain_text_password [, skip_validation => 1 ] });
841 Set the patron's password.
845 The passed string is validated against the current password enforcement policy.
846 Validation can be skipped by passing the I<skip_validation> parameter.
848 Exceptions are thrown if the password is not good enough.
852 =item Koha::Exceptions::Password::TooShort
854 =item Koha::Exceptions::Password::WhitespaceCharacters
856 =item Koha::Exceptions::Password::TooWeak
858 =item Koha::Exceptions::Password::Plugin (if a "check password" plugin is enabled)
865 my ( $self, $args ) = @_;
867 my $password = $args->{password};
869 unless ( $args->{skip_validation} ) {
870 my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $password, $self->category );
873 if ( $error eq 'too_short' ) {
874 my $min_length = $self->category->effective_min_password_length;
875 $min_length = 3 if not $min_length or $min_length < 3;
877 my $password_length = length($password);
878 Koha::Exceptions::Password::TooShort->throw(
879 length => $password_length, min_length => $min_length );
881 elsif ( $error eq 'has_whitespaces' ) {
882 Koha::Exceptions::Password::WhitespaceCharacters->throw();
884 elsif ( $error eq 'too_weak' ) {
885 Koha::Exceptions::Password::TooWeak->throw();
890 if ( C4::Context->config("enable_plugins") ) {
891 # Call any check_password plugins
892 my @plugins = Koha::Plugins->new()->GetPlugins({
893 method => 'check_password',
895 foreach my $plugin ( @plugins ) {
896 # This plugin hook will also be used by a plugin for the Norwegian national
897 # patron database. This is why we need to pass both the password and the
898 # borrowernumber to the plugin.
899 my $ret = $plugin->check_password(
901 password => $password,
902 borrowernumber => $self->borrowernumber
905 # This plugin hook will also be used by a plugin for the Norwegian national
906 # patron database. This is why we need to call the actual plugins and then
907 # check skip_validation afterwards.
908 if ( $ret->{'error'} == 1 && !$args->{skip_validation} ) {
909 Koha::Exceptions::Password::Plugin->throw();
914 if ( C4::Context->preference('NotifyPasswordChange') ) {
915 my $self_from_storage = $self->get_from_storage;
916 if ( !C4::Auth::checkpw_hash( $password, $self_from_storage->password ) ) {
917 my $emailaddr = $self_from_storage->notice_email_address;
919 # if we manage to find a valid email address, send notice
921 my $letter = C4::Letters::GetPreparedLetter(
923 letter_code => 'PASSWORD_CHANGE',
924 branchcode => $self_from_storage->branchcode,
926 lang => $self_from_storage->lang || 'default',
928 'branches' => $self_from_storage->branchcode,
929 'borrowers' => $self_from_storage->borrowernumber,
934 my $message_id = C4::Letters::EnqueueLetter(
937 borrowernumber => $self_from_storage->id,
938 to_address => $emailaddr,
939 message_transport_type => 'email'
942 C4::Letters::SendQueuedMessages( { message_id => $message_id } ) if $message_id;
947 my $digest = Koha::AuthUtils::hash_password($password);
949 $self->password_expiration_date( $self->category->get_password_expiry_date || undef );
951 # We do not want to call $self->store and retrieve password from DB
952 $self->password($digest);
953 $self->login_attempts(0);
956 logaction( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" )
957 if C4::Context->preference("BorrowersLog");
965 my $new_expiry_date = $patron->renew_account
967 Extending the subscription to the expiry date.
974 if ( C4::Context->preference('BorrowerRenewalPeriodBase') eq 'combination' ) {
975 $date = ( dt_from_string gt dt_from_string( $self->dateexpiry ) ) ? dt_from_string : dt_from_string( $self->dateexpiry );
978 C4::Context->preference('BorrowerRenewalPeriodBase') eq 'dateexpiry'
979 ? dt_from_string( $self->dateexpiry )
982 my $expiry_date = $self->category->get_expiry_date($date);
984 $self->dateexpiry($expiry_date);
985 $self->date_renewed( dt_from_string() );
988 $self->add_enrolment_fee_if_needed(1);
990 logaction( "MEMBERS", "RENEW", $self->borrowernumber, "Membership renewed" ) if C4::Context->preference("BorrowersLog");
991 return dt_from_string( $expiry_date )->truncate( to => 'day' );
996 my $has_overdues = $patron->has_overdues;
998 Returns the number of patron's overdues
1004 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1005 return $self->_result->issues->search({ date_due => { '<' => $dtf->format_datetime( dt_from_string() ) } })->count;
1010 $patron->track_login;
1011 $patron->track_login({ force => 1 });
1013 Tracks a (successful) login attempt.
1014 The preference TrackLastPatronActivity must be enabled. Or you
1015 should pass the force parameter.
1020 my ( $self, $params ) = @_;
1022 !$params->{force} &&
1023 !C4::Context->preference('TrackLastPatronActivity');
1024 $self->lastseen( dt_from_string() )->store;
1027 =head3 move_to_deleted
1029 my $is_moved = $patron->move_to_deleted;
1031 Move a patron to the deletedborrowers table.
1032 This can be done before deleting a patron, to make sure the data are not completely deleted.
1036 sub move_to_deleted {
1038 my $patron_infos = $self->unblessed;
1039 delete $patron_infos->{updated_on}; #This ensures the updated_on date in deletedborrowers will be set to the current timestamp
1040 return Koha::Database->new->schema->resultset('Deletedborrower')->create($patron_infos);
1043 =head3 can_request_article
1045 if ( $patron->can_request_article( $library->id ) ) { ... }
1047 Returns true if the patron can request articles. As limits apply for the patron
1048 on the same day, those completed the same day are considered as current.
1050 A I<library_id> can be passed as parameter, falling back to userenv if absent.
1054 sub can_request_article {
1055 my ($self, $library_id) = @_;
1057 $library_id //= C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1059 my $rule = Koha::CirculationRules->get_effective_rule(
1061 branchcode => $library_id,
1062 categorycode => $self->categorycode,
1063 rule_name => 'open_article_requests_limit'
1067 my $limit = ($rule) ? $rule->rule_value : undef;
1069 return 1 unless defined $limit;
1071 my $count = Koha::ArticleRequests->search(
1072 [ { borrowernumber => $self->borrowernumber, status => [ 'REQUESTED', 'PENDING', 'PROCESSING' ] },
1073 { borrowernumber => $self->borrowernumber, status => 'COMPLETED', updated_on => { '>=' => \'CAST(NOW() AS DATE)' } },
1076 return $count < $limit ? 1 : 0;
1079 =head3 article_request_fee
1081 my $fee = $patron->article_request_fee(
1083 [ library_id => $library->id, ]
1087 Returns the fee to be charged to the patron when it places an article request.
1089 A I<library_id> can be passed as parameter, falling back to userenv if absent.
1093 sub article_request_fee {
1094 my ($self, $params) = @_;
1096 my $library_id = $params->{library_id};
1098 $library_id //= C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1100 my $rule = Koha::CirculationRules->get_effective_rule(
1102 branchcode => $library_id,
1103 categorycode => $self->categorycode,
1104 rule_name => 'article_request_fee'
1108 my $fee = ($rule) ? $rule->rule_value + 0 : 0;
1113 =head3 add_article_request_fee_if_needed
1115 my $fee = $patron->add_article_request_fee_if_needed(
1117 [ item_id => $item->id,
1118 library_id => $library->id, ]
1122 If an article request fee needs to be charged, it adds a debit to the patron's
1125 Returns the fee line.
1127 A I<library_id> can be passed as parameter, falling back to userenv if absent.
1131 sub add_article_request_fee_if_needed {
1132 my ($self, $params) = @_;
1134 my $library_id = $params->{library_id};
1135 my $item_id = $params->{item_id};
1137 $library_id //= C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef;
1139 my $amount = $self->article_request_fee(
1141 library_id => $library_id,
1147 if ( $amount > 0 ) {
1148 $debit_line = $self->account->add_debit(
1151 user_id => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
1152 interface => C4::Context->interface,
1153 library_id => $library_id,
1154 type => 'ARTICLE_REQUEST',
1155 item_id => $item_id,
1163 =head3 article_requests
1165 my $article_requests = $patron->article_requests;
1167 Returns the patron article requests.
1171 sub article_requests {
1174 return Koha::ArticleRequests->_new_from_dbic( scalar $self->_result->article_requests );
1177 =head3 add_enrolment_fee_if_needed
1179 my $enrolment_fee = $patron->add_enrolment_fee_if_needed($renewal);
1181 Add enrolment fee for a patron if needed.
1183 $renewal - boolean denoting whether this is an account renewal or not
1187 sub add_enrolment_fee_if_needed {
1188 my ($self, $renewal) = @_;
1189 my $enrolment_fee = $self->category->enrolmentfee;
1190 if ( $enrolment_fee && $enrolment_fee > 0 ) {
1191 my $type = $renewal ? 'ACCOUNT_RENEW' : 'ACCOUNT';
1192 $self->account->add_debit(
1194 amount => $enrolment_fee,
1195 user_id => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
1196 interface => C4::Context->interface,
1197 library_id => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
1202 return $enrolment_fee || 0;
1207 my $checkouts = $patron->checkouts
1213 my $checkouts = $self->_result->issues;
1214 return Koha::Checkouts->_new_from_dbic( $checkouts );
1217 =head3 pending_checkouts
1219 my $pending_checkouts = $patron->pending_checkouts
1221 This method will return the same as $self->checkouts, but with a prefetch on
1222 items, biblio and biblioitems.
1224 It has been introduced to replaced the C4::Members::GetPendingIssues subroutine
1226 It should not be used directly, prefer to access fields you need instead of
1227 retrieving all these fields in one go.
1231 sub pending_checkouts {
1233 my $checkouts = $self->_result->issues->search(
1237 { -desc => 'me.timestamp' },
1238 { -desc => 'issuedate' },
1239 { -desc => 'issue_id' }, # Sort by issue_id should be enough
1241 prefetch => { item => { biblio => 'biblioitems' } },
1244 return Koha::Checkouts->_new_from_dbic( $checkouts );
1247 =head3 old_checkouts
1249 my $old_checkouts = $patron->old_checkouts
1255 my $old_checkouts = $self->_result->old_issues;
1256 return Koha::Old::Checkouts->_new_from_dbic( $old_checkouts );
1261 my $overdue_items = $patron->overdues
1263 Return the overdue items
1269 my $dtf = Koha::Database->new->schema->storage->datetime_parser;
1270 return $self->checkouts->search(
1272 'me.date_due' => { '<' => $dtf->format_datetime(dt_from_string) },
1275 prefetch => { item => { biblio => 'biblioitems' } },
1283 my $restrictions = $patron->restrictions;
1285 Returns the patron restrictions.
1291 my $restrictions_rs = $self->_result->restrictions;
1292 return Koha::Patron::Restrictions->_new_from_dbic($restrictions_rs);
1295 =head3 get_routing_lists
1297 my $routinglists = $patron->get_routing_lists
1299 Returns the routing lists a patron is subscribed to.
1303 sub get_routing_lists {
1305 my $routing_list_rs = $self->_result->subscriptionroutinglists;
1306 return Koha::Subscription::Routinglists->_new_from_dbic($routing_list_rs);
1311 my $age = $patron->get_age
1313 Return the age of the patron
1320 return unless $self->dateofbirth;
1322 #Set timezone to floating to avoid any datetime math issues caused by DST
1323 my $date_of_birth = dt_from_string( $self->dateofbirth, undef, 'floating' );
1324 my $today = dt_from_string(undef, undef, 'floating')->truncate( to => 'day' );
1326 return $today->subtract_datetime( $date_of_birth )->years;
1331 my $is_valid = $patron->is_valid_age
1333 Return 1 if patron's age is between allowed limits, returns 0 if it's not.
1339 my $age = $self->get_age;
1341 my $patroncategory = $self->category;
1342 my ($low,$high) = ($patroncategory->dateofbirthrequired, $patroncategory->upperagelimit);
1344 return (defined($age) && (($high && ($age > $high)) or ($low && ($age < $low)))) ? 0 : 1;
1349 my $account = $patron->account
1355 return Koha::Account->new( { patron_id => $self->borrowernumber } );
1360 my $holds = $patron->holds
1362 Return all the holds placed by this patron
1368 my $holds_rs = $self->_result->reserves->search( {}, { order_by => 'reservedate' } );
1369 return Koha::Holds->_new_from_dbic($holds_rs);
1374 my $old_holds = $patron->old_holds
1376 Return all the historical holds for this patron
1382 my $old_holds_rs = $self->_result->old_reserves->search( {}, { order_by => 'reservedate' } );
1383 return Koha::Old::Holds->_new_from_dbic($old_holds_rs);
1386 =head3 curbside_pickups
1388 my $curbside_pickups = $patron->curbside_pickups;
1390 Return all the curbside pickups for this patron
1394 sub curbside_pickups {
1396 my $curbside_pickups_rs = $self->_result->curbside_pickups_borrowernumbers->search;
1397 return Koha::CurbsidePickups->_new_from_dbic($curbside_pickups_rs);
1400 =head3 return_claims
1402 my $return_claims = $patron->return_claims
1408 my $return_claims = $self->_result->return_claims_borrowernumbers;
1409 return Koha::Checkouts::ReturnClaims->_new_from_dbic( $return_claims );
1412 =head3 notice_email_address
1414 my $email = $patron->notice_email_address;
1416 Return the email address of patron used for notices.
1417 Returns the empty string if no email address.
1421 sub notice_email_address{
1424 my $which_address = C4::Context->preference("EmailFieldPrimary");
1425 # if syspref is set to 'first valid' (value == OFF), look up email address
1426 if ( $which_address eq 'OFF' ) {
1427 return $self->first_valid_email_address;
1430 return $self->$which_address || '';
1433 =head3 first_valid_email_address
1435 my $first_valid_email_address = $patron->first_valid_email_address
1437 Return the first valid email address for a patron.
1438 For now, the order is defined as email, emailpro, B_email.
1439 Returns the empty string if the borrower has no email addresses.
1443 sub first_valid_email_address {
1448 my @fields = split /\s*\|\s*/,
1449 C4::Context->preference('EmailFieldPrecedence');
1450 for my $field (@fields) {
1451 $email = $self->$field;
1458 =head3 get_club_enrollments
1462 sub get_club_enrollments {
1465 return Koha::Club::Enrollments->search( { borrowernumber => $self->borrowernumber(), date_canceled => undef } );
1468 =head3 get_enrollable_clubs
1472 sub get_enrollable_clubs {
1473 my ( $self, $is_enrollable_from_opac ) = @_;
1476 $params->{is_enrollable_from_opac} = $is_enrollable_from_opac
1477 if $is_enrollable_from_opac;
1478 $params->{is_email_required} = 0 unless $self->first_valid_email_address();
1480 $params->{borrower} = $self;
1482 return Koha::Clubs->get_enrollable($params);
1485 =head3 account_locked
1487 my $is_locked = $patron->account_locked
1489 Return true if the patron has reached the maximum number of login attempts
1490 (see pref FailedLoginAttempts). If login_attempts is < 0, this is interpreted
1491 as an administrative lockout (independent of FailedLoginAttempts; see also
1492 Koha::Patron->lock).
1493 Otherwise return false.
1494 If the pref is not set (empty string, null or 0), the feature is considered as
1499 sub account_locked {
1501 my $FailedLoginAttempts = C4::Context->preference('FailedLoginAttempts');
1502 return 1 if $FailedLoginAttempts
1503 and $self->login_attempts
1504 and $self->login_attempts >= $FailedLoginAttempts;
1505 return 1 if ($self->login_attempts || 0) < 0; # administrative lockout
1509 =head3 can_see_patron_infos
1511 my $can_see = $patron->can_see_patron_infos( $patron );
1513 Return true if the patron (usually the logged in user) can see the patron's infos for a given patron
1517 sub can_see_patron_infos {
1518 my ( $self, $patron ) = @_;
1519 return unless $patron;
1520 return $self->can_see_patrons_from( $patron->branchcode );
1523 =head3 can_see_patrons_from
1525 my $can_see = $patron->can_see_patrons_from( $branchcode );
1527 Return true if the patron (usually the logged in user) can see the patron's infos from a given library
1531 sub can_see_patrons_from {
1532 my ( $self, $branchcode ) = @_;
1534 return $self->can_see_things_from(
1536 branchcode => $branchcode,
1537 permission => 'borrowers',
1538 subpermission => 'view_borrower_infos_from_any_libraries',
1543 =head3 can_edit_items_from
1545 my $can_edit = $patron->can_edit_items_from( $branchcode );
1547 Return true if the I<Koha::Patron> can edit items from the given branchcode
1551 sub can_edit_items_from {
1552 my ( $self, $branchcode ) = @_;
1554 return 1 if C4::Context->IsSuperLibrarian();
1556 my $userenv = C4::Context->userenv();
1557 if ( $userenv && C4::Context->preference('IndependentBranches') ) {
1558 return $userenv->{branch} eq $branchcode;
1561 return $self->can_see_things_from(
1563 branchcode => $branchcode,
1564 permission => 'editcatalogue',
1565 subpermission => 'edit_any_item',
1570 =head3 libraries_where_can_edit_items
1572 my $libraries = $patron->libraries_where_can_edit_items;
1574 Return the list of branchcodes(!) of libraries the patron is allowed to items for.
1575 The branchcodes are arbitrarily returned sorted.
1576 We are supposing here that the object is related to the logged in patron (use of C4::Context::only_my_library)
1578 An empty array means no restriction, the user can edit any item.
1582 sub libraries_where_can_edit_items {
1585 return $self->libraries_where_can_see_things(
1587 permission => 'editcatalogue',
1588 subpermission => 'edit_any_item',
1589 group_feature => 'ft_limit_item_editing',
1594 =head3 libraries_where_can_see_patrons
1596 my $libraries = $patron->libraries_where_can_see_patrons;
1598 Return the list of branchcodes(!) of libraries the patron is allowed to see other patron's infos.
1599 The branchcodes are arbitrarily returned sorted.
1600 We are supposing here that the object is related to the logged in patron (use of C4::Context::only_my_library)
1602 An empty array means no restriction, the patron can see patron's infos from any libraries.
1606 sub libraries_where_can_see_patrons {
1609 return $self->libraries_where_can_see_things(
1611 permission => 'borrowers',
1612 subpermission => 'view_borrower_infos_from_any_libraries',
1613 group_feature => 'ft_hide_patron_info',
1618 =head3 can_see_things_from
1620 my $can_see = $patron->can_see_things_from( $branchcode );
1622 Return true if the I<Koha::Patron> can perform some action on the given thing
1626 sub can_see_things_from {
1627 my ( $self, $params ) = @_;
1629 my $branchcode = $params->{branchcode};
1630 my $permission = $params->{permission};
1631 my $subpermission = $params->{subpermission};
1633 return 1 if C4::Context->IsSuperLibrarian();
1636 if ( $self->branchcode eq $branchcode ) {
1638 } elsif ( $self->has_permission( { $permission => $subpermission } ) ) {
1640 } elsif ( my $library_groups = $self->library->library_groups ) {
1641 while ( my $library_group = $library_groups->next ) {
1642 if ( $library_group->parent->has_child( $branchcode ) ) {
1653 my $can_log_into = $patron->can_log_into( $library );
1655 Given a I<Koha::Library> object, it returns a boolean representing
1656 the fact the patron can log into a the library.
1661 my ( $self, $library ) = @_;
1665 if ( C4::Context->preference('IndependentBranches') ) {
1667 if $self->is_superlibrarian
1668 or $self->branchcode eq $library->id;
1678 =head3 libraries_where_can_see_things
1680 my $libraries = $patron->libraries_where_can_see_things;
1682 Returns a list of libraries where an aribitarary action is allowed to be taken by the logged in librarian
1683 against an object based on some branchcode related to the object ( patron branchcode, item homebranch, etc ).
1685 We are supposing here that the object is related to the logged in librarian (use of C4::Context::only_my_library)
1687 An empty array means no restriction, the thing can see thing's infos from any libraries.
1691 sub libraries_where_can_see_things {
1692 my ( $self, $params ) = @_;
1693 my $permission = $params->{permission};
1694 my $subpermission = $params->{subpermission};
1695 my $group_feature = $params->{group_feature};
1697 my $userenv = C4::Context->userenv;
1699 return () unless $userenv; # For tests, but userenv should be defined in tests...
1701 my @restricted_branchcodes;
1702 if (C4::Context::only_my_library) {
1703 push @restricted_branchcodes, $self->branchcode;
1707 $self->has_permission(
1708 { $permission => $subpermission }
1712 my $library_groups = $self->library->library_groups({ $group_feature => 1 });
1713 if ( $library_groups->count )
1715 while ( my $library_group = $library_groups->next ) {
1716 my $parent = $library_group->parent;
1717 if ( $parent->has_child( $self->branchcode ) ) {
1718 push @restricted_branchcodes, $parent->children->get_column('branchcode');
1723 @restricted_branchcodes = ( $self->branchcode ) unless @restricted_branchcodes;
1727 @restricted_branchcodes = grep { defined $_ } @restricted_branchcodes;
1728 @restricted_branchcodes = uniq(@restricted_branchcodes);
1729 @restricted_branchcodes = sort(@restricted_branchcodes);
1730 return @restricted_branchcodes;
1733 =head3 has_permission
1735 my $permission = $patron->has_permission($required);
1737 See C4::Auth::haspermission for details of syntax for $required
1741 sub has_permission {
1742 my ( $self, $flagsrequired ) = @_;
1743 return unless $self->userid;
1744 # TODO code from haspermission needs to be moved here!
1745 return C4::Auth::haspermission( $self->userid, $flagsrequired );
1748 =head3 is_superlibrarian
1750 my $is_superlibrarian = $patron->is_superlibrarian;
1752 Return true if the patron is a superlibrarian.
1756 sub is_superlibrarian {
1758 return $self->has_permission( { superlibrarian => 1 } ) ? 1 : 0;
1763 my $is_adult = $patron->is_adult
1765 Return true if the patron has a category with a type Adult (A) or Organization (I)
1771 return $self->category->category_type =~ /^(A|I)$/ ? 1 : 0;
1776 my $is_child = $patron->is_child
1778 Return true if the patron has a category with a type Child (C)
1784 return $self->category->category_type eq 'C' ? 1 : 0;
1787 =head3 has_valid_userid
1789 my $patron = Koha::Patrons->find(42);
1790 $patron->userid( $new_userid );
1791 my $has_a_valid_userid = $patron->has_valid_userid
1793 my $patron = Koha::Patron->new( $params );
1794 my $has_a_valid_userid = $patron->has_valid_userid
1796 Return true if the current userid of this patron is valid/unique, otherwise false.
1798 Note that this should be done in $self->store instead and raise an exception if needed.
1802 sub has_valid_userid {
1805 return 0 unless $self->userid;
1807 return 0 if ( $self->userid eq C4::Context->config('user') ); # DB user
1809 my $already_exists = Koha::Patrons->search(
1811 userid => $self->userid,
1814 ? ( borrowernumber => { '!=' => $self->borrowernumber } )
1819 return $already_exists ? 0 : 1;
1822 =head3 generate_userid
1824 $patron->generate_userid;
1826 If you do not have a plugin for generating a userid, we will call
1827 the internal method here that returns firstname.surname[.number],
1828 where number is an optional suffix to make the userid unique.
1829 (Its behavior has not been changed on bug 32426.)
1831 If you have plugin(s), the first valid response will be used.
1832 A plugin is assumed to return a valid userid as suggestion, but not
1833 assumed to save it already.
1834 Does not fallback to internal (you could arrange for that in your plugin).
1835 Clears userid when there are no valid plugin responses.
1839 sub generate_userid {
1841 my @responses = Koha::Plugins->call(
1842 'patron_generate_userid', { patron => $self },
1844 unless( @responses ) {
1845 # Empty list only possible when there are NO enabled plugins for this method.
1846 # In that case we provide internal response.
1847 return $self->_generate_userid_internal;
1849 # If a plugin returned false value or invalid value, we do however not return
1850 # internal response. The plugins should deal with that themselves. So we prevent
1851 # unexpected/unwelcome internal codes for plugin failures.
1852 foreach my $response ( grep { $_ } @responses ) {
1853 $self->userid( $response );
1854 return $self if $self->has_valid_userid;
1856 $self->userid(undef);
1860 sub _generate_userid_internal { # as we always did
1863 my $firstname = $self->firstname // q{};
1864 my $surname = $self->surname // q{};
1865 #The script will "do" the following code and increment the $offset until the generated userid is unique
1867 $firstname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1868 $surname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
1869 my $userid = lc(($firstname)? "$firstname.$surname" : $surname);
1870 $userid = NFKD( $userid );
1871 $userid =~ s/\p{NonspacingMark}//g;
1872 $userid .= $offset unless $offset == 0;
1873 $self->userid( $userid );
1875 } while (! $self->has_valid_userid );
1880 =head3 add_extended_attribute
1884 sub add_extended_attribute {
1885 my ($self, $attribute) = @_;
1887 return Koha::Patron::Attribute->new(
1890 ( borrowernumber => $self->borrowernumber ),
1896 =head3 extended_attributes
1898 Return object of Koha::Patron::Attributes type with all attributes set for this patron
1904 sub extended_attributes {
1905 my ( $self, $attributes ) = @_;
1906 if ($attributes) { # setter
1907 my $schema = $self->_result->result_source->schema;
1910 # Remove the existing one
1911 $self->extended_attributes->filter_by_branch_limitations->delete;
1913 # Insert the new ones
1915 for my $attribute (@$attributes) {
1916 $self->add_extended_attribute($attribute);
1917 $new_types->{$attribute->{code}} = 1;
1920 # Check globally mandatory types
1921 my @required_attribute_types =
1922 Koha::Patron::Attribute::Types->search(
1925 category_code => [ undef, $self->categorycode ],
1926 'borrower_attribute_types_branches.b_branchcode' =>
1929 { join => 'borrower_attribute_types_branches' }
1930 )->get_column('code');
1931 for my $type ( @required_attribute_types ) {
1932 Koha::Exceptions::Patron::MissingMandatoryExtendedAttribute->throw(
1934 ) if !$new_types->{$type};
1940 my $rs = $self->_result->borrower_attributes;
1941 # We call search to use the filters in Koha::Patron::Attributes->search
1942 return Koha::Patron::Attributes->_new_from_dbic($rs)->search;
1947 my $messages = $patron->messages;
1949 Return the message attached to the patron.
1955 my $messages_rs = $self->_result->messages_borrowernumbers->search;
1956 return Koha::Patron::Messages->_new_from_dbic($messages_rs);
1961 Koha::Patrons->find($id)->lock({ expire => 1, remove => 1 });
1963 Lock and optionally expire a patron account.
1964 Remove holds and article requests if remove flag set.
1965 In order to distinguish from locking by entering a wrong password, let's
1966 call this an administrative lockout.
1971 my ( $self, $params ) = @_;
1972 $self->login_attempts( ADMINISTRATIVE_LOCKOUT );
1973 if( $params->{expire} ) {
1974 $self->dateexpiry( dt_from_string->subtract(days => 1) );
1977 if( $params->{remove} ) {
1978 $self->holds->delete;
1979 $self->article_requests->delete;
1986 Koha::Patrons->find($id)->anonymize;
1988 Anonymize or clear borrower fields. Fields in BorrowerMandatoryField
1989 are randomized, other personal data is cleared too.
1990 Patrons with issues are skipped.
1996 if( $self->_result->issues->count ) {
1997 warn "Exiting anonymize: patron ".$self->borrowernumber." still has issues";
2000 # Mandatory fields come from the corresponding pref, but email fields
2001 # are removed since scrambled email addresses only generate errors
2002 my $mandatory = { map { (lc $_, 1); } grep { !/email/ }
2003 split /\s*\|\s*/, C4::Context->preference('BorrowerMandatoryField') };
2004 $mandatory->{userid} = 1; # needed since sub store does not clear field
2005 my @columns = $self->_result->result_source->columns;
2006 @columns = grep { !/borrowernumber|branchcode|categorycode|^date|password|flags|updated_on|lastseen|lang|login_attempts|anonymized|auth_method/ } @columns;
2007 push @columns, 'dateofbirth'; # add this date back in
2008 foreach my $col (@columns) {
2009 $self->_anonymize_column($col, $mandatory->{lc $col} );
2011 $self->anonymized(1)->store;
2014 sub _anonymize_column {
2015 my ( $self, $col, $mandatory ) = @_;
2016 my $col_info = $self->_result->result_source->column_info($col);
2017 my $type = $col_info->{data_type};
2018 my $nullable = $col_info->{is_nullable};
2020 if( $type =~ /char|text/ ) {
2022 ? Koha::Token->new->generate({ pattern => '\w{10}' })
2026 } elsif( $type =~ /integer|int$|float|dec|double/ ) {
2027 $val = $nullable ? undef : 0;
2028 } elsif( $type =~ /date|time/ ) {
2029 $val = $nullable ? undef : dt_from_string;
2034 =head3 add_guarantor
2036 my $relationship = $patron->add_guarantor(
2038 borrowernumber => $borrowernumber,
2039 relationships => $relationship,
2043 Adds a new guarantor to a patron.
2048 my ( $self, $params ) = @_;
2050 my $guarantor_id = $params->{guarantor_id};
2051 my $relationship = $params->{relationship};
2053 return Koha::Patron::Relationship->new(
2055 guarantee_id => $self->id,
2056 guarantor_id => $guarantor_id,
2057 relationship => $relationship
2062 =head3 get_extended_attribute
2064 my $attribute_value = $patron->get_extended_attribute( $code );
2066 Return the attribute for the code passed in parameter.
2068 It not exist it returns undef
2070 Note that this will not work for repeatable attribute types.
2072 Maybe you certainly not want to use this method, it is actually only used for SHOW_BARCODE
2073 (which should be a real patron's attribute (not extended)
2077 sub get_extended_attribute {
2078 my ( $self, $code, $value ) = @_;
2079 my $rs = $self->_result->borrower_attributes;
2081 my $attribute = $rs->search({ code => $code, ( $value ? ( attribute => $value ) : () ) });
2082 return unless $attribute->count;
2083 return $attribute->next;
2088 my $json = $patron->to_api;
2090 Overloaded method that returns a JSON representation of the Koha::Patron object,
2091 suitable for API output.
2096 my ( $self, $params ) = @_;
2098 my $json_patron = $self->SUPER::to_api( $params );
2100 $json_patron->{restricted} = ( $self->is_debarred )
2102 : Mojo::JSON->false;
2104 return $json_patron;
2107 =head3 to_api_mapping
2109 This method returns the mapping for representing a Koha::Patron object
2114 sub to_api_mapping {
2116 borrowernotes => 'staff_notes',
2117 borrowernumber => 'patron_id',
2118 branchcode => 'library_id',
2119 categorycode => 'category_id',
2120 checkprevcheckout => 'check_previous_checkout',
2121 contactfirstname => undef, # Unused
2122 contactname => undef, # Unused
2123 contactnote => 'altaddress_notes',
2124 contacttitle => undef, # Unused
2125 dateenrolled => 'date_enrolled',
2126 dateexpiry => 'expiry_date',
2127 dateofbirth => 'date_of_birth',
2128 debarred => undef, # replaced by 'restricted'
2129 debarredcomment => undef, # calculated, API consumers will use /restrictions instead
2130 emailpro => 'secondary_email',
2131 flags => undef, # permissions manipulation handled in /permissions
2132 gonenoaddress => 'incorrect_address',
2133 lastseen => 'last_seen',
2134 lost => 'patron_card_lost',
2135 opacnote => 'opac_notes',
2136 othernames => 'other_name',
2137 password => undef, # password manipulation handled in /password
2138 phonepro => 'secondary_phone',
2139 relationship => 'relationship_type',
2141 smsalertnumber => 'sms_number',
2142 sort1 => 'statistics_1',
2143 sort2 => 'statistics_2',
2144 autorenew_checkouts => 'autorenew_checkouts',
2145 streetnumber => 'street_number',
2146 streettype => 'street_type',
2147 zipcode => 'postal_code',
2148 B_address => 'altaddress_address',
2149 B_address2 => 'altaddress_address2',
2150 B_city => 'altaddress_city',
2151 B_country => 'altaddress_country',
2152 B_email => 'altaddress_email',
2153 B_phone => 'altaddress_phone',
2154 B_state => 'altaddress_state',
2155 B_streetnumber => 'altaddress_street_number',
2156 B_streettype => 'altaddress_street_type',
2157 B_zipcode => 'altaddress_postal_code',
2158 altcontactaddress1 => 'altcontact_address',
2159 altcontactaddress2 => 'altcontact_address2',
2160 altcontactaddress3 => 'altcontact_city',
2161 altcontactcountry => 'altcontact_country',
2162 altcontactfirstname => 'altcontact_firstname',
2163 altcontactphone => 'altcontact_phone',
2164 altcontactsurname => 'altcontact_surname',
2165 altcontactstate => 'altcontact_state',
2166 altcontactzipcode => 'altcontact_postal_code',
2167 password_expiration_date => undef,
2168 primary_contact_method => undef,
2170 auth_method => undef,
2176 Koha::Patrons->queue_notice({ letter_params => $letter_params, message_name => 'DUE'});
2177 Koha::Patrons->queue_notice({ letter_params => $letter_params, message_transports => \@message_transports });
2178 Koha::Patrons->queue_notice({ letter_params => $letter_params, message_transports => \@message_transports, test_mode => 1 });
2180 Queue messages to a patron. Can pass a message that is part of the message_attributes
2181 table or supply the transport to use.
2183 If passed a message name we retrieve the patrons preferences for transports
2184 Otherwise we use the supplied transport. In the case of email or sms we fall back to print if
2185 we have no address/number for sending
2187 $letter_params is a hashref of the values to be passed to GetPreparedLetter
2189 test_mode will only report which notices would be sent, but nothing will be queued
2194 my ( $self, $params ) = @_;
2195 my $letter_params = $params->{letter_params};
2196 my $test_mode = $params->{test_mode};
2198 return unless $letter_params;
2199 return unless exists $params->{message_name} xor $params->{message_transports}; # We only want one of these
2201 my $library = Koha::Libraries->find( $letter_params->{branchcode} );
2202 my $from_email_address = $library->from_email_address;
2204 my @message_transports;
2206 $letter_code = $letter_params->{letter_code};
2207 if( $params->{message_name} ){
2208 my $messaging_prefs = C4::Members::Messaging::GetMessagingPreferences( {
2209 borrowernumber => $letter_params->{borrowernumber},
2210 message_name => $params->{message_name}
2212 @message_transports = ( keys %{ $messaging_prefs->{transports} } );
2213 $letter_code = $messaging_prefs->{transports}->{$message_transports[0]} unless $letter_code;
2215 @message_transports = @{$params->{message_transports}};
2217 return unless defined $letter_code;
2218 $letter_params->{letter_code} = $letter_code;
2221 foreach my $mtt (@message_transports){
2222 next if ($mtt eq 'itiva' and C4::Context->preference('TalkingTechItivaPhoneNotification') );
2223 # Notice is handled by TalkingTech_itiva_outbound.pl
2224 if ( ( $mtt eq 'email' and not $self->notice_email_address )
2225 or ( $mtt eq 'sms' and not $self->smsalertnumber )
2226 or ( $mtt eq 'phone' and not $self->phone ) )
2228 push @{ $return{fallback} }, $mtt;
2231 next if $mtt eq 'print' && $print_sent;
2232 $letter_params->{message_transport_type} = $mtt;
2233 my $letter = C4::Letters::GetPreparedLetter( %$letter_params );
2234 C4::Letters::EnqueueLetter({
2236 borrowernumber => $self->borrowernumber,
2237 from_address => $from_email_address,
2238 message_transport_type => $mtt
2239 }) unless $test_mode;
2240 push @{$return{sent}}, $mtt;
2241 $print_sent = 1 if $mtt eq 'print';
2246 =head3 safe_to_delete
2248 my $result = $patron->safe_to_delete;
2249 if ( $result eq 'has_guarantees' ) { ... }
2250 elsif ( $result ) { ... }
2251 else { # cannot delete }
2253 This method tells if the Koha:Patron object can be deleted. Possible return values
2259 =item 'has_checkouts'
2263 =item 'has_guarantees'
2265 =item 'is_anonymous_patron'
2271 sub safe_to_delete {
2274 my $anonymous_patron = C4::Context->preference('AnonymousPatron');
2278 if ( $anonymous_patron && $self->id eq $anonymous_patron ) {
2279 $error = 'is_anonymous_patron';
2281 elsif ( $self->checkouts->count ) {
2282 $error = 'has_checkouts';
2284 elsif ( $self->account->outstanding_debits->total_outstanding > 0 ) {
2285 $error = 'has_debt';
2287 elsif ( $self->guarantee_relationships->count ) {
2288 $error = 'has_guarantees';
2292 return Koha::Result::Boolean->new(0)->add_message({ message => $error });
2295 return Koha::Result::Boolean->new(1);
2300 my $recalls = $patron->recalls;
2302 Return the patron's recalls.
2309 return Koha::Recalls->search({ patron_id => $self->borrowernumber });
2312 =head3 account_balance
2314 my $balance = $patron->account_balance
2316 Return the patron's account balance
2320 sub account_balance {
2322 return $self->account->balance;
2325 =head3 notify_library_of_registration
2327 $patron->notify_library_of_registration( $email_patron_registrations );
2329 Send patron registration email to library if EmailPatronRegistrations system preference is enabled.
2333 sub notify_library_of_registration {
2334 my ( $self, $email_patron_registrations ) = @_;
2337 my $letter = C4::Letters::GetPreparedLetter(
2338 module => 'members',
2339 letter_code => 'OPAC_REG',
2340 branchcode => $self->branchcode,
2341 lang => $self->lang || 'default',
2343 'borrowers' => $self->borrowernumber
2348 if ( $email_patron_registrations eq "BranchEmailAddress" ) {
2349 my $library = Koha::Libraries->find( $self->branchcode );
2350 $to_address = $library->inbound_email_address;
2352 elsif ( $email_patron_registrations eq "KohaAdminEmailAddress" ) {
2353 $to_address = C4::Context->preference('ReplytoDefault')
2354 || C4::Context->preference('KohaAdminEmailAddress');
2358 C4::Context->preference('EmailAddressForPatronRegistrations')
2359 || C4::Context->preference('ReplytoDefault')
2360 || C4::Context->preference('KohaAdminEmailAddress');
2363 my $message_id = C4::Letters::EnqueueLetter(
2366 borrowernumber => $self->borrowernumber,
2367 to_address => $to_address,
2368 message_transport_type => 'email'
2370 ) or warn "can't enqueue letter $letter";
2371 if ( $message_id ) {
2377 =head3 has_messaging_preference
2379 my $bool = $patron->has_messaging_preference({
2380 message_name => $message_name, # A value from message_attributes.message_name
2381 message_transport_type => $message_transport_type, # email, sms, phone, itiva, etc...
2382 wants_digest => $wants_digest, # 1 if you are looking for the digest version, don't pass if you just want either
2387 sub has_messaging_preference {
2388 my ( $self, $params ) = @_;
2390 my $message_name = $params->{message_name};
2391 my $message_transport_type = $params->{message_transport_type};
2392 my $wants_digest = $params->{wants_digest};
2394 return $self->_result->search_related_rs(
2395 'borrower_message_preferences',
2399 [ 'borrower_message_transport_preferences', 'message_attribute' ]
2404 =head3 can_patron_change_staff_only_lists
2406 $patron->can_patron_change_staff_only_lists;
2408 Return 1 if a patron has 'Superlibrarian' or 'Catalogue' permission.
2409 Otherwise, return 0.
2413 sub can_patron_change_staff_only_lists {
2414 my ( $self, $params ) = @_;
2415 return 1 if C4::Auth::haspermission( $self->userid, { 'catalogue' => 1 });
2419 =head3 can_patron_change_permitted_staff_lists
2421 $patron->can_patron_change_permitted_staff_lists;
2423 Return 1 if a patron has 'Superlibrarian' or 'Catalogue' and 'edit_public_list_contents' permissions.
2424 Otherwise, return 0.
2428 sub can_patron_change_permitted_staff_lists {
2429 my ( $self, $params ) = @_;
2430 return 1 if C4::Auth::haspermission( $self->userid, { 'catalogue' => 1, lists => 'edit_public_list_contents' } );
2434 =head3 encode_secret
2436 $patron->encode_secret($secret32);
2438 Secret (TwoFactorAuth expects it in base32 format) is encrypted.
2439 You still need to call ->store.
2444 my ( $self, $secret ) = @_;
2446 return $self->secret( Koha::Encryption->new->encrypt_hex($secret) );
2448 return $self->secret($secret);
2451 =head3 decoded_secret
2453 my $secret32 = $patron->decoded_secret;
2455 Decode the patron secret. We expect to get back a base32 string, but this
2456 is not checked here. Caller of encode_secret is responsible for that.
2460 sub decoded_secret {
2462 if( $self->secret ) {
2463 return Koha::Encryption->new->decrypt_hex( $self->secret );
2465 return $self->secret;
2468 =head3 virtualshelves
2470 my $shelves = $patron->virtualshelves;
2474 sub virtualshelves {
2476 return Koha::Virtualshelves->_new_from_dbic( scalar $self->_result->virtualshelves );
2481 my $savings = $patron->get_savings;
2483 Use the replacement price of patron's old and current issues to calculate how much they have 'saved' by using the library.
2490 my @itemnumbers = grep { defined $_ } ( $self->old_checkouts->get_column('itemnumber'), $self->checkouts->get_column('itemnumber') );
2492 return Koha::Items->search(
2493 { itemnumber => { -in => \@itemnumbers } },
2494 { select => [ { sum => 'me.replacementprice' } ],
2495 as => ['total_savings']
2497 )->next->get_column('total_savings') // 0;
2500 =head2 Internal methods
2512 Kyle M Hall <kyle@bywatersolutions.com>
2513 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
2514 Martin Renvoize <martin.renvoize@ptfs-europe.com>