Bug 10352: Display the correct modification logs for bibliographic records
[koha.git] / Koha / Patron.pm
bloba3ebfc9b2e2fead22127bc145f59419c14da49fb
1 package Koha::Patron;
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
11 # version.
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.
21 use Modern::Perl;
23 use Carp;
24 use List::MoreUtils qw( any uniq );
25 use JSON qw( to_json );
26 use Text::Unaccent qw( unac_string );
28 use C4::Context;
29 use C4::Log;
30 use Koha::Account;
31 use Koha::AuthUtils;
32 use Koha::Checkouts;
33 use Koha::Club::Enrollments;
34 use Koha::Database;
35 use Koha::DateUtils;
36 use Koha::Exceptions::Password;
37 use Koha::Holds;
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;
45 use Koha::Patrons;
46 use Koha::Plugins;
47 use Koha::Subscription::Routinglists;
48 use Koha::Token;
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',
85 =head1 NAME
87 Koha::Patron - Koha Patron Object class
89 =head1 API
91 =head2 Class Methods
93 =head3 new
95 =cut
97 sub new {
98 my ( $class, $params ) = @_;
100 return $class->SUPER::new($params);
103 =head3 fixup_cardnumber
105 Autogenerate next cardnumber from highest value found in database
107 =cut
109 sub fixup_cardnumber {
110 my ( $self ) = @_;
111 my $max = Koha::Patrons->search({
112 cardnumber => {-regexp => '^-?[0-9]+$'}
113 }, {
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
125 =cut
127 sub trim_whitespaces {
128 my( $self ) = @_;
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);
140 return $self;
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
150 =cut
152 sub plain_text_password {
153 my ( $self, $password ) = @_;
154 if ( $password ) {
155 $self->{_plain_text_password} = $password;
156 return $self;
158 return $self->{_plain_text_password}
159 if $self->{_plain_text_password};
161 return;
164 =head3 store
166 Patron specific store method to cleanup record
167 and do other necessary things before saving
168 to db
170 =cut
172 sub store {
173 my ($self) = @_;
175 $self->_result->result_source->schema->txn_do(
176 sub {
177 if (
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{};
224 $default_privacy =
225 $default_privacy eq 'default' ? 1
226 : $default_privacy eq 'never' ? 2
227 : $default_privacy eq 'forever' ? 0
228 : undef;
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")
234 && $self->password )
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 )
261 : '!' );
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");
272 else { #ModMember
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' );
299 # Actionlogs
300 if ( C4::Context->preference("BorrowersLog") ) {
301 my $info;
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;
307 if (
309 !defined( $from_storage->{$key} )
310 && defined( $from_object->{$key} )
312 || ( defined( $from_storage->{$key} )
313 && !defined( $from_object->{$key} ) )
314 || (
315 defined( $from_storage->{$key} )
316 && defined( $from_object->{$key} )
317 && ( $from_storage->{$key} ne
318 $from_object->{$key} )
322 $info->{$key} = {
323 before => $from_storage->{$key},
324 after => $from_object->{$key}
329 if ( defined($info) ) {
330 logaction(
331 "MEMBERS",
332 "MODIFY",
333 $self->borrowernumber,
334 to_json(
335 $info,
336 { utf8 => 1, pretty => 1, canonical => 1 }
342 # Final store
343 $self = $self->SUPER::store;
347 return $self;
350 =head3 delete
352 $patron->delete
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.
359 =cut
361 sub delete {
362 my ($self) = @_;
364 $self->_result->result_source->schema->txn_do(
365 sub {
366 # Cancel Patron's holds
367 my $holds = $self->holds;
368 while( my $hold = $holds->next ){
369 $hold->cancel;
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.
377 # NOTE:
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");
393 return $self;
397 =head3 category
399 my $patron_category = $patron->category
401 Return the patron category for this patron
403 =cut
405 sub category {
406 my ( $self ) = @_;
407 return Koha::Patron::Category->_new_from_dbic( $self->_result->categorycode );
410 =head3 image
412 =cut
414 sub image {
415 my ( $self ) = @_;
417 return scalar Koha::Patron::Images->find( $self->borrowernumber );
420 =head3 library
422 Returns a Koha::Library object representing the patron's home library.
424 =cut
426 sub library {
427 my ( $self ) = @_;
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.
441 =cut
443 sub guarantor_relationships {
444 my ($self) = @_;
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.
459 =cut
461 sub guarantee_relationships {
462 my ($self) = @_;
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.
477 =cut
479 sub housebound_profile {
480 my ( $self ) = @_;
481 my $profile = $self->_result->housebound_profile;
482 return Koha::Patron::HouseboundProfile->_new_from_dbic($profile)
483 if ( $profile );
484 return;
487 =head3 housebound_role
489 Returns the HouseboundRole associated with this patron.
491 =cut
493 sub housebound_role {
494 my ( $self ) = @_;
496 my $role = $self->_result->housebound_role;
497 return Koha::Patron::HouseboundRole->_new_from_dbic($role) if ( $role );
498 return;
501 =head3 siblings
503 Returns the siblings of this patron.
505 =cut
507 sub siblings {
508 my ($self) = @_;
510 my @guarantors = $self->guarantor_relationships()->guarantors();
512 return unless @guarantors;
514 my @siblings =
515 map { $_->guarantee_relationships()->guarantees() } @guarantors;
517 return unless @siblings;
519 my %seen;
520 @siblings =
521 grep { !$seen{ $_->id }++ && ( $_->id != $self->id ) } @siblings;
523 return wantarray ? @siblings : Koha::Patrons->search( { borrowernumber => { -in => [ map { $_->id } @siblings ] } } );
526 =head3 merge_with
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.
535 =cut
537 sub merge_with {
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();
547 my $results;
549 $self->_result->result_source->schema->txn_do( sub {
550 foreach my $patron_id (@patron_ids) {
551 my $patron = Koha::Patrons->find( $patron_id );
553 next unless $patron;
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();
565 $patron->delete();
569 return $results;
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.
580 =cut
582 sub wants_check_for_previous_checkout {
583 my ( $self ) = @_;
584 my $syspref = C4::Context->preference("checkPrevCheckout");
586 # Simple cases
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') {
601 return 1;
602 } else {
603 return 0;
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.
614 =cut
616 sub do_check_for_previous_checkout {
617 my ( $self, $item ) = @_;
619 my @item_nos;
620 my $biblio = Koha::Biblios->find( $item->{biblionumber} );
621 if ( $biblio->is_serial ) {
622 push @item_nos, $item->{itemnumber};
623 } else {
624 # Get all itemnumbers for given bibliographic record.
625 @item_nos = $biblio->items->get_column( 'itemnumber' );
628 # Create (old)issues search criteria
629 my $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
643 =head3 is_debarred
645 my $debarment_expiration = $patron->is_debarred;
647 Returns the date a patron debarment will expire, or undef if the patron is not
648 debarred
650 =cut
652 sub is_debarred {
653 my ($self) = @_;
655 return unless $self->debarred;
656 return $self->debarred
657 if $self->debarred =~ '^9999'
658 or dt_from_string( $self->debarred ) > dt_from_string;
659 return;
662 =head3 is_expired
664 my $is_expired = $patron->is_expired;
666 Returns 1 if the patron is expired or 0;
668 =cut
670 sub is_expired {
671 my ($self) = @_;
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' );
675 return 0;
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
684 =cut
686 sub is_going_to_expire {
687 my ($self) = @_;
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' );
695 return 0;
698 =head3 set_password
700 $patron->set_password({ password => $plain_text_password [, skip_validation => 1 ] });
702 Set the patron's password.
704 =head4 Exceptions
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.
711 =over 4
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)
721 =back
723 =cut
725 sub set_password {
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 );
733 if ( !$is_valid ) {
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);
780 $self->SUPER::store;
782 logaction( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" )
783 if C4::Context->preference("BorrowersLog");
785 return $self;
789 =head3 renew_account
791 my $new_expiry_date = $patron->renew_account
793 Extending the subscription to the expiry date.
795 =cut
797 sub renew_account {
798 my ($self) = @_;
799 my $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 );
802 } else {
803 $date =
804 C4::Context->preference('BorrowerRenewalPeriodBase') eq 'dateexpiry'
805 ? dt_from_string( $self->dateexpiry )
806 : dt_from_string;
808 my $expiry_date = $self->category->get_expiry_date($date);
810 $self->dateexpiry($expiry_date);
811 $self->date_renewed( dt_from_string() );
812 $self->store();
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' );
820 =head3 has_overdues
822 my $has_overdues = $patron->has_overdues;
824 Returns the number of patron's overdues
826 =cut
828 sub has_overdues {
829 my ($self) = @_;
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;
834 =head3 track_login
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.
843 =cut
845 sub track_login {
846 my ( $self, $params ) = @_;
847 return if
848 !$params->{force} &&
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.
860 =cut
862 sub move_to_deleted {
863 my ($self) = @_;
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
876 calling context.
878 =cut
880 sub article_requests {
881 my ( $self ) = @_;
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
894 =cut
896 sub article_requests_current {
897 my ( $self ) = @_;
899 $self->{_article_requests_current} ||= Koha::ArticleRequests->search(
901 borrowernumber => $self->id(),
902 -or => [
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
918 =cut
920 sub article_requests_finished {
921 my ( $self, $borrower ) = @_;
923 $self->{_article_requests_finished} ||= Koha::ArticleRequests->search(
925 borrowernumber => $self->id(),
926 -or => [
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
944 =cut
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,
957 type => $type
961 return $enrolment_fee || 0;
964 =head3 checkouts
966 my $checkouts = $patron->checkouts
968 =cut
970 sub checkouts {
971 my ($self) = @_;
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.
988 =cut
990 sub pending_checkouts {
991 my( $self ) = @_;
992 my $checkouts = $self->_result->issues->search(
995 order_by => [
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
1010 =cut
1012 sub old_checkouts {
1013 my ($self) = @_;
1014 my $old_checkouts = $self->_result->old_issues;
1015 return Koha::Old::Checkouts->_new_from_dbic( $old_checkouts );
1018 =head3 get_overdues
1020 my $overdue_items = $patron->get_overdues
1022 Return the overdue items
1024 =cut
1026 sub get_overdues {
1027 my ($self) = @_;
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.
1045 =cut
1047 sub get_routing_lists {
1048 my ($self) = @_;
1049 my $routing_list_rs = $self->_result->subscriptionroutinglists;
1050 return Koha::Subscription::Routinglists->_new_from_dbic($routing_list_rs);
1053 =head3 get_age
1055 my $age = $patron->get_age
1057 Return the age of the patron
1059 =cut
1061 sub get_age {
1062 my ($self) = @_;
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 ) {
1072 $age--;
1075 return $age;
1078 =head3 is_valid_age
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.
1084 =cut
1086 sub is_valid_age {
1087 my ($self) = @_;
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;
1096 =head3 account
1098 my $account = $patron->account
1100 =cut
1102 sub account {
1103 my ($self) = @_;
1104 return Koha::Account->new( { patron_id => $self->borrowernumber } );
1107 =head3 holds
1109 my $holds = $patron->holds
1111 Return all the holds placed by this patron
1113 =cut
1115 sub holds {
1116 my ($self) = @_;
1117 my $holds_rs = $self->_result->reserves->search( {}, { order_by => 'reservedate' } );
1118 return Koha::Holds->_new_from_dbic($holds_rs);
1121 =head3 old_holds
1123 my $old_holds = $patron->old_holds
1125 Return all the historical holds for this patron
1127 =cut
1129 sub old_holds {
1130 my ($self) = @_;
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
1139 =cut
1141 sub return_claims {
1142 my ($self) = @_;
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.
1154 =cut
1156 sub notice_email_address{
1157 my ( $self ) = @_;
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.
1176 =cut
1178 sub first_valid_email_address {
1179 my ($self) = @_;
1181 return $self->email() || $self->emailpro() || $self->B_email() || q{};
1184 =head3 get_club_enrollments
1186 =cut
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
1200 =cut
1202 sub get_enrollable_clubs {
1203 my ( $self, $is_enrollable_from_opac, $return_scalar ) = @_;
1205 my $params;
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
1229 disabled.
1231 =cut
1233 sub account_locked {
1234 my ($self) = @_;
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
1240 return 0;
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
1249 =cut
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
1263 =cut
1265 sub can_see_patrons_from {
1266 my ( $self, $branchcode ) = @_;
1267 my $can = 0;
1268 if ( $self->branchcode eq $branchcode ) {
1269 $can = 1;
1270 } elsif ( $self->has_permission( { borrowers => 'view_borrower_infos_from_any_libraries' } ) ) {
1271 $can = 1;
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 ) ) {
1275 $can = 1;
1276 last;
1280 return $can;
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.
1293 =cut
1295 sub libraries_where_can_see_patrons {
1296 my ( $self ) = @_;
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;
1305 else {
1306 unless (
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 );
1340 =head3 is_adult
1342 my $is_adult = $patron->is_adult
1344 Return true if the patron has a category with a type Adult (A) or Organization (I)
1346 =cut
1348 sub is_adult {
1349 my ( $self ) = @_;
1350 return $self->category->category_type =~ /^(A|I)$/ ? 1 : 0;
1353 =head3 is_child
1355 my $is_child = $patron->is_child
1357 Return true if the patron has a category with a type Child (C)
1359 =cut
1361 sub is_child {
1362 my( $self ) = @_;
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.
1379 =cut
1381 sub has_valid_userid {
1382 my ($self) = @_;
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,
1392 $self->in_storage
1393 ? ( borrowernumber => { '!=' => $self->borrowernumber } )
1394 : ()
1397 )->count;
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).
1410 =cut
1412 sub generate_userid {
1413 my ($self) = @_;
1414 my $offset = 0;
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
1418 do {
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 = unac_string('utf-8',$userid);
1423 $userid .= $offset unless $offset == 0;
1424 $self->userid( $userid );
1425 $offset++;
1426 } while (! $self->has_valid_userid );
1428 return $self;
1432 =head3 attributes
1434 my $attributes = $patron->attributes
1436 Return object of Koha::Patron::Attributes type with all attributes set for this patron
1438 =cut
1440 sub attributes {
1441 my ( $self ) = @_;
1442 return Koha::Patron::Attributes->search({
1443 borrowernumber => $self->borrowernumber,
1444 branchcode => $self->branchcode,
1448 =head3 lock
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.
1457 =cut
1459 sub lock {
1460 my ( $self, $params ) = @_;
1461 $self->login_attempts( ADMINISTRATIVE_LOCKOUT );
1462 if( $params->{expire} ) {
1463 $self->dateexpiry( dt_from_string->subtract(days => 1) );
1465 $self->store;
1466 if( $params->{remove} ) {
1467 $self->holds->delete;
1468 $self->article_requests->delete;
1470 return $self;
1473 =head3 anonymize
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.
1481 =cut
1483 sub anonymize {
1484 my ( $self ) = @_;
1485 if( $self->_result->issues->count ) {
1486 warn "Exiting anonymize: patron ".$self->borrowernumber." still has issues";
1487 return;
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};
1508 my $val;
1509 if( $type =~ /char|text/ ) {
1510 $val = $mandatory
1511 ? Koha::Token->new->generate({ pattern => '\w{10}' })
1512 : $nullable
1513 ? undef
1514 : q{};
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;
1520 $self->$col($val);
1523 =head3 add_guarantor
1525 my @relationships = $patron->add_guarantor(
1527 borrowernumber => $borrowernumber,
1528 relationships => $relationship,
1532 Adds a new guarantor to a patron.
1534 =cut
1536 sub add_guarantor {
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
1548 )->store();
1551 =head3 to_api
1553 my $json = $patron->to_api;
1555 Overloaded method that returns a JSON representation of the Koha::Patron object,
1556 suitable for API output.
1558 =cut
1560 sub to_api {
1561 my ( $self ) = @_;
1563 my $json_patron = $self->SUPER::to_api;
1565 $json_patron->{restricted} = ( $self->is_debarred )
1566 ? Mojo::JSON->true
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
1575 on the API.
1577 =cut
1579 sub to_api_mapping {
1580 return {
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',
1606 sex => 'gender',
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
1637 =head3 _type
1639 =cut
1641 sub _type {
1642 return 'Borrower';
1645 =head1 AUTHORS
1647 Kyle M Hall <kyle@bywatersolutions.com>
1648 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
1649 Martin Renvoize <martin.renvoize@ptfs-europe.com>
1651 =cut