Bug 24545: Fix license statements
[srvgit] / Koha / Patron.pm
index 27499fc..e6d7538 100644 (file)
@@ -5,51 +5,53 @@ package Koha::Patron;
 #
 # This file is part of Koha.
 #
 #
 # This file is part of Koha.
 #
-# Koha is free software; you can redistribute it and/or modify it under the
-# terms of the GNU General Public License as published by the Free Software
-# Foundation; either version 3 of the License, or (at your option) any later
-# version.
+# Koha is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
 #
 #
-# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+# Koha is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
 #
 #
-# You should have received a copy of the GNU General Public License along
-# with Koha; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+# You should have received a copy of the GNU General Public License
+# along with Koha; if not, see <http://www.gnu.org/licenses>.
 
 use Modern::Perl;
 
 use Carp;
 
 use Modern::Perl;
 
 use Carp;
-use List::MoreUtils qw( uniq );
+use List::MoreUtils qw( any uniq );
 use JSON qw( to_json );
 use JSON qw( to_json );
-use Module::Load::Conditional qw( can_load );
-use Text::Unaccent qw( unac_string );
+use Unicode::Normalize;
 
 use C4::Context;
 use C4::Log;
 
 use C4::Context;
 use C4::Log;
+use Koha::Account;
 use Koha::AuthUtils;
 use Koha::Checkouts;
 use Koha::AuthUtils;
 use Koha::Checkouts;
+use Koha::Club::Enrollments;
 use Koha::Database;
 use Koha::DateUtils;
 use Koha::Database;
 use Koha::DateUtils;
+use Koha::Exceptions::Password;
 use Koha::Holds;
 use Koha::Old::Checkouts;
 use Koha::Holds;
 use Koha::Old::Checkouts;
+use Koha::Patron::Attributes;
 use Koha::Patron::Categories;
 use Koha::Patron::HouseboundProfile;
 use Koha::Patron::HouseboundRole;
 use Koha::Patron::Images;
 use Koha::Patron::Categories;
 use Koha::Patron::HouseboundProfile;
 use Koha::Patron::HouseboundRole;
 use Koha::Patron::Images;
+use Koha::Patron::Relationships;
 use Koha::Patrons;
 use Koha::Patrons;
-use Koha::Virtualshelves;
-use Koha::Club::Enrollments;
-use Koha::Account;
+use Koha::Plugins;
 use Koha::Subscription::Routinglists;
 use Koha::Subscription::Routinglists;
-
-if ( ! can_load( modules => { 'Koha::NorwegianPatronDB' => undef } ) ) {
-   warn "Unable to load Koha::NorwegianPatronDB";
-}
+use Koha::Token;
+use Koha::Virtualshelves;
 
 use base qw(Koha::Object);
 
 
 use base qw(Koha::Object);
 
+use constant ADMINISTRATIVE_LOCKOUT => -1;
+
 our $RESULTSET_PATRON_ID_MAPPING = {
     Accountline          => 'borrowernumber',
     Aqbasketuser         => 'borrowernumber',
 our $RESULTSET_PATRON_ID_MAPPING = {
     Accountline          => 'borrowernumber',
     Aqbasketuser         => 'borrowernumber',
@@ -88,8 +90,6 @@ Koha::Patron - Koha Patron Object class
 
 =head2 Class Methods
 
 
 =head2 Class Methods
 
-=cut
-
 =head3 new
 
 =cut
 =head3 new
 
 =cut
@@ -195,13 +195,13 @@ sub store {
 
             $self->trim_whitespaces;
 
 
             $self->trim_whitespaces;
 
-            # We don't want invalid dates in the db (mysql has a bad habit of inserting 0000-00-00)
-            $self->dateofbirth(undef) unless $self->dateofbirth;
-            $self->debarred(undef)    unless $self->debarred;
+            # Set surname to uppercase if uppercasesurname is true
+            $self->surname( uc($self->surname) )
+                if C4::Context->preference("uppercasesurnames");
 
 
-            # Set default values if not set
-            $self->sms_provider_id(undef) unless $self->sms_provider_id;
-            $self->guarantorid(undef)     unless $self->guarantorid;
+            $self->relationship(undef) # We do not want to store an empty string in this field
+              if defined $self->relationship
+                     and $self->relationship eq "";
 
             unless ( $self->in_storage ) {    #AddMember
 
 
             unless ( $self->in_storage ) {    #AddMember
 
@@ -228,8 +228,28 @@ sub store {
                   :                                                   undef;
                 $self->privacy($default_privacy);
 
                   :                                                   undef;
                 $self->privacy($default_privacy);
 
-                unless ( defined $self->privacy_guarantor_checkouts ) {
-                    $self->privacy_guarantor_checkouts(0);
+                # Call any check_password plugins if password is passed
+                if (   C4::Context->preference('UseKohaPlugins')
+                    && C4::Context->config("enable_plugins")
+                    && $self->password )
+                {
+                    my @plugins = Koha::Plugins->new()->GetPlugins({
+                        method => 'check_password',
+                    });
+                    foreach my $plugin ( @plugins ) {
+                        # This plugin hook will also be used by a plugin for the Norwegian national
+                        # patron database. This is why we need to pass both the password and the
+                        # borrowernumber to the plugin.
+                        my $ret = $plugin->check_password(
+                            {
+                                password       => $self->password,
+                                borrowernumber => $self->borrowernumber
+                            }
+                        );
+                        if ( $ret->{'error'} == 1 ) {
+                            Koha::Exceptions::Password::Plugin->throw();
+                        }
+                    }
                 }
 
                 # Make a copy of the plain text password for later use
                 }
 
                 # Make a copy of the plain text password for later use
@@ -244,99 +264,82 @@ sub store {
 
                 $self = $self->SUPER::store;
 
 
                 $self = $self->SUPER::store;
 
-                # If NorwegianPatronDBEnable is enabled, we set syncstatus to something that a
-                # cronjob will use for syncing with NL
-                if (   C4::Context->preference('NorwegianPatronDBEnable')
-                    && C4::Context->preference('NorwegianPatronDBEnable') == 1 )
-                {
-                    Koha::Database->new->schema->resultset('BorrowerSync')
-                      ->create(
-                        {
-                            'borrowernumber' => $self->borrowernumber,
-                            'synctype'       => 'norwegianpatrondb',
-                            'sync'           => 1,
-                            'syncstatus'     => 'new',
-                            'hashed_pin' =>
-                              Koha::NorwegianPatronDB::NLEncryptPIN($self->plain_text_password),
-                        }
-                      );
-                }
-
-                $self->add_enrolment_fee_if_needed;
+                $self->add_enrolment_fee_if_needed(0);
 
                 logaction( "MEMBERS", "CREATE", $self->borrowernumber, "" )
                   if C4::Context->preference("BorrowersLog");
             }
             else {    #ModMember
 
                 logaction( "MEMBERS", "CREATE", $self->borrowernumber, "" )
                   if C4::Context->preference("BorrowersLog");
             }
             else {    #ModMember
-                # We could add a test here to make sure the password is not update (?)
-
-                # Come from ModMember, but should not be possible (?)
-                $self->dateenrolled(undef) unless $self->dateenrolled;
-                $self->dateexpiry(undef)   unless $self->dateexpiry;
 
 
+                my $self_from_storage = $self->get_from_storage;
                 # FIXME We should not deal with that here, callers have to do this job
                 # Moved from ModMember to prevent regressions
                 unless ( $self->userid ) {
                 # FIXME We should not deal with that here, callers have to do this job
                 # Moved from ModMember to prevent regressions
                 unless ( $self->userid ) {
-                    my $stored_userid = $self->get_from_storage->userid;
+                    my $stored_userid = $self_from_storage->userid;
                     $self->userid($stored_userid);
                 }
 
                     $self->userid($stored_userid);
                 }
 
-                if ( C4::Context->preference('FeeOnChangePatronCategory')
-                    and $self->category->categorycode ne
-                    $self->get_from_storage->category->categorycode )
-                {
-                    $self->add_enrolment_fee_if_needed;
-                }
+                # Password must be updated using $self->set_password
+                $self->password($self_from_storage->password);
 
 
-                # If NorwegianPatronDBEnable is enabled, we set syncstatus to something that a
-                # cronjob will use for syncing with NL
-                if (   C4::Context->preference('NorwegianPatronDBEnable')
-                    && C4::Context->preference('NorwegianPatronDBEnable') == 1 )
+                if ( $self->category->categorycode ne
+                    $self_from_storage->category->categorycode )
                 {
                 {
-                    my $borrowersync = Koha::Database->new->schema->resultset('BorrowerSync')->find({
-                        'synctype'       => 'norwegianpatrondb',
-                        'borrowernumber' => $self->borrowernumber,
-                    });
-                    # Do not set to "edited" if syncstatus is "new". We need to sync as new before
-                    # we can sync as changed. And the "new sync" will pick up all changes since
-                    # the patron was created anyway.
-                    if ( $borrowersync->syncstatus ne 'new' && $borrowersync->syncstatus ne 'delete' ) {
-                        $borrowersync->update( { 'syncstatus' => 'edited' } );
-                    }
-                    # Set the value of 'sync'
-                    # FIXME THIS IS BROKEN # $borrowersync->update( { 'sync' => $data{'sync'} } );
+                    # Add enrolement fee on category change if required
+                    $self->add_enrolment_fee_if_needed(1)
+                      if C4::Context->preference('FeeOnChangePatronCategory');
 
 
-                    # Try to do the live sync
-                    Koha::NorwegianPatronDB::NLSync({ 'borrowernumber' => $self->borrowernumber });
-                }
+                    # Clean up guarantors on category change if required
+                    $self->guarantor_relationships->delete
+                      if ( $self->category->category_type ne 'C'
+                        && $self->category->category_type ne 'P' );
 
 
-                my $borrowers_log = C4::Context->preference("BorrowersLog");
-                my $previous_cardnumber = $self->get_from_storage->cardnumber;
-                if ($borrowers_log
-                    && ( !defined $previous_cardnumber
-                        || $previous_cardnumber ne $self->cardnumber )
-                    )
-                {
-                    logaction(
-                        "MEMBERS",
-                        "MODIFY",
-                        $self->borrowernumber,
-                        to_json(
-                            {
-                                cardnumber_replaced => {
-                                    previous_cardnumber => $previous_cardnumber,
-                                    new_cardnumber      => $self->cardnumber,
-                                }
-                            },
-                            { utf8 => 1, pretty => 1 }
-                        )
-                    );
                 }
 
                 }
 
-                logaction( "MEMBERS", "MODIFY", $self->borrowernumber,
-                    "UPDATE (executed w/ arg: " . $self->borrowernumber . ")" )
-                  if $borrowers_log;
+                # Actionlogs
+                if ( C4::Context->preference("BorrowersLog") ) {
+                    my $info;
+                    my $from_storage = $self_from_storage->unblessed;
+                    my $from_object  = $self->unblessed;
+                    my @skip_fields  = (qw/lastseen updated_on/);
+                    for my $key ( keys %{$from_storage} ) {
+                        next if any { /$key/ } @skip_fields;
+                        if (
+                            (
+                                  !defined( $from_storage->{$key} )
+                                && defined( $from_object->{$key} )
+                            )
+                            || ( defined( $from_storage->{$key} )
+                                && !defined( $from_object->{$key} ) )
+                            || (
+                                   defined( $from_storage->{$key} )
+                                && defined( $from_object->{$key} )
+                                && ( $from_storage->{$key} ne
+                                    $from_object->{$key} )
+                            )
+                          )
+                        {
+                            $info->{$key} = {
+                                before => $from_storage->{$key},
+                                after  => $from_object->{$key}
+                            };
+                        }
+                    }
+
+                    if ( defined($info) ) {
+                        logaction(
+                            "MEMBERS",
+                            "MODIFY",
+                            $self->borrowernumber,
+                            to_json(
+                                $info,
+                                { utf8 => 1, pretty => 1, canonical => 1 }
+                            )
+                        );
+                    }
+                }
 
 
+                # Final store
                 $self = $self->SUPER::store;
             }
         }
                 $self = $self->SUPER::store;
             }
         }
@@ -358,11 +361,13 @@ other lists are kept.
 sub delete {
     my ($self) = @_;
 
 sub delete {
     my ($self) = @_;
 
-    my $deleted;
     $self->_result->result_source->schema->txn_do(
         sub {
     $self->_result->result_source->schema->txn_do(
         sub {
-            # Delete Patron's holds
-            $self->holds->delete;
+            # Cancel Patron's holds
+            my $holds = $self->holds;
+            while( my $hold = $holds->next ){
+                $hold->cancel;
+            }
 
             # Delete all lists and all shares of this borrower
             # Consistent with the approach Koha uses on deleting individual lists
 
             # Delete all lists and all shares of this borrower
             # Consistent with the approach Koha uses on deleting individual lists
@@ -380,12 +385,12 @@ sub delete {
             # FIXME Could be $patron->get_lists
             $_->delete for Koha::Virtualshelves->search( { owner => $self->borrowernumber } );
 
             # FIXME Could be $patron->get_lists
             $_->delete for Koha::Virtualshelves->search( { owner => $self->borrowernumber } );
 
-            $deleted = $self->SUPER::delete;
+            $self->SUPER::delete;
 
             logaction( "MEMBERS", "DELETE", $self->borrowernumber, "" ) if C4::Context->preference("BorrowersLog");
         }
     );
 
             logaction( "MEMBERS", "DELETE", $self->borrowernumber, "" ) if C4::Context->preference("BorrowersLog");
         }
     );
-    return $deleted;
+    return $self;
 }
 
 
 }
 
 
@@ -402,41 +407,67 @@ sub category {
     return Koha::Patron::Category->_new_from_dbic( $self->_result->categorycode );
 }
 
     return Koha::Patron::Category->_new_from_dbic( $self->_result->categorycode );
 }
 
-=head3 guarantor
-
-Returns a Koha::Patron object for this patron's guarantor
+=head3 image
 
 =cut
 
 
 =cut
 
-sub guarantor {
+sub image {
     my ( $self ) = @_;
 
     my ( $self ) = @_;
 
-    return unless $self->guarantorid();
-
-    return Koha::Patrons->find( $self->guarantorid() );
+    return Koha::Patron::Images->find( $self->borrowernumber );
 }
 
 }
 
-sub image {
-    my ( $self ) = @_;
+=head3 library
 
 
-    return scalar Koha::Patron::Images->find( $self->borrowernumber );
-}
+Returns a Koha::Library object representing the patron's home library.
+
+=cut
 
 sub library {
     my ( $self ) = @_;
     return Koha::Library->_new_from_dbic($self->_result->branchcode);
 }
 
 
 sub library {
     my ( $self ) = @_;
     return Koha::Library->_new_from_dbic($self->_result->branchcode);
 }
 
-=head3 guarantees
+=head3 guarantor_relationships
 
 
-Returns the guarantees (list of Koha::Patron) of this patron
+Returns Koha::Patron::Relationships object for this patron's guarantors
+
+Returns the set of relationships for the patrons that are guarantors for this patron.
+
+This is returned instead of a Koha::Patron object because the guarantor
+may not exist as a patron in Koha. If this is true, the guarantors name
+exists in the Koha::Patron::Relationship object and will have no guarantor_id.
 
 =cut
 
 
 =cut
 
-sub guarantees {
-    my ( $self ) = @_;
+sub guarantor_relationships {
+    my ($self) = @_;
+
+    return Koha::Patron::Relationships->search( { guarantee_id => $self->id } );
+}
+
+=head3 guarantee_relationships
+
+Returns Koha::Patron::Relationships object for this patron's guarantors
+
+Returns the set of relationships for the patrons that are guarantees for this patron.
 
 
-    return Koha::Patrons->search( { guarantorid => $self->borrowernumber } );
+The method returns Koha::Patron::Relationship objects for the sake
+of consistency with the guantors method.
+A guarantee by definition must exist as a patron in Koha.
+
+=cut
+
+sub guarantee_relationships {
+    my ($self) = @_;
+
+    return Koha::Patron::Relationships->search(
+        { guarantor_id => $self->id },
+        {
+            prefetch => 'guarantee',
+            order_by => { -asc => [ 'guarantee.surname', 'guarantee.firstname' ] },
+        }
+    );
 }
 
 =head3 housebound_profile
 }
 
 =head3 housebound_profile
@@ -474,23 +505,22 @@ Returns the siblings of this patron.
 =cut
 
 sub siblings {
 =cut
 
 sub siblings {
-    my ( $self ) = @_;
+    my ($self) = @_;
 
 
-    my $guarantor = $self->guarantor;
+    my @guarantors = $self->guarantor_relationships()->guarantors();
 
 
-    return unless $guarantor;
+    return unless @guarantors;
 
 
-    return Koha::Patrons->search(
-        {
-            guarantorid => {
-                '!=' => undef,
-                '=' => $guarantor->id,
-            },
-            borrowernumber => {
-                '!=' => $self->borrowernumber,
-            }
-        }
-    );
+    my @siblings =
+      map { $_->guarantee_relationships()->guarantees() } @guarantors;
+
+    return unless @siblings;
+
+    my %seen;
+    @siblings =
+      grep { !$seen{ $_->id }++ && ( $_->id != $self->id ) } @siblings;
+
+    return wantarray ? @siblings : Koha::Patrons->search( { borrowernumber => { -in => [ map { $_->id } @siblings ] } } );
 }
 
 =head3 merge_with
 }
 
 =head3 merge_with
@@ -586,11 +616,13 @@ $PATRON, 0 otherwise.
 sub do_check_for_previous_checkout {
     my ( $self, $item ) = @_;
 
 sub do_check_for_previous_checkout {
     my ( $self, $item ) = @_;
 
-    # Find all items for bib and extract item numbers.
-    my @items = Koha::Items->search({biblionumber => $item->{biblionumber}});
     my @item_nos;
     my @item_nos;
-    foreach my $item (@items) {
-        push @item_nos, $item->itemnumber;
+    my $biblio = Koha::Biblios->find( $item->{biblionumber} );
+    if ( $biblio->is_serial ) {
+        push @item_nos, $item->{itemnumber};
+    } else {
+        # Get all itemnumbers for given bibliographic record.
+        @item_nos = $biblio->items->get_column( 'itemnumber' );
     }
 
     # Create (old)issues search criteria
     }
 
     # Create (old)issues search criteria
@@ -659,44 +691,101 @@ sub is_going_to_expire {
     return 0 unless $delay;
     return 0 unless $self->dateexpiry;
     return 0 if $self->dateexpiry =~ '^9999';
     return 0 unless $delay;
     return 0 unless $self->dateexpiry;
     return 0 if $self->dateexpiry =~ '^9999';
-    return 1 if dt_from_string( $self->dateexpiry )->subtract( days => $delay ) < dt_from_string->truncate( to => 'day' );
+    return 1 if dt_from_string( $self->dateexpiry, undef, 'floating' )->subtract( days => $delay ) < dt_from_string(undef, undef, 'floating')->truncate( to => 'day' );
     return 0;
 }
 
     return 0;
 }
 
-=head3 update_password
+=head3 set_password
 
 
-my $updated = $patron->update_password( $userid, $password );
+    $patron->set_password({ password => $plain_text_password [, skip_validation => 1 ] });
 
 
-Update the userid and the password of a patron.
-If the userid already exists, returns and let DBIx::Class warns
-This will add an entry to action_logs if BorrowersLog is set.
+Set the patron's password.
+
+=head4 Exceptions
+
+The passed string is validated against the current password enforcement policy.
+Validation can be skipped by passing the I<skip_validation> parameter.
+
+Exceptions are thrown if the password is not good enough.
+
+=over 4
+
+=item Koha::Exceptions::Password::TooShort
+
+=item Koha::Exceptions::Password::WhitespaceCharacters
+
+=item Koha::Exceptions::Password::TooWeak
+
+=item Koha::Exceptions::Password::Plugin (if a "check password" plugin is enabled)
+
+=back
 
 =cut
 
 
 =cut
 
-sub update_password {
-    my ( $self, $userid, $password ) = @_;
-    eval { $self->userid($userid)->store; };
-    return if $@; # Make sure the userid is not already in used by another patron
+sub set_password {
+    my ( $self, $args ) = @_;
 
 
-    return 0 if $password eq '****' or $password eq ''; # Do we need that?
+    my $password = $args->{password};
 
 
-    if ( C4::Context->preference('NorwegianPatronDBEnable') && C4::Context->preference('NorwegianPatronDBEnable') == 1 ) {
-        # Update the hashed PIN in borrower_sync.hashed_pin, before Koha hashes it
-        Koha::NorwegianPatronDB::NLUpdateHashedPIN( $self->borrowernumber, $password );
+    unless ( $args->{skip_validation} ) {
+        my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $password );
+
+        if ( !$is_valid ) {
+            if ( $error eq 'too_short' ) {
+                my $min_length = C4::Context->preference('minPasswordLength');
+                $min_length = 3 if not $min_length or $min_length < 3;
+
+                my $password_length = length($password);
+                Koha::Exceptions::Password::TooShort->throw(
+                    length => $password_length, min_length => $min_length );
+            }
+            elsif ( $error eq 'has_whitespaces' ) {
+                Koha::Exceptions::Password::WhitespaceCharacters->throw();
+            }
+            elsif ( $error eq 'too_weak' ) {
+                Koha::Exceptions::Password::TooWeak->throw();
+            }
+        }
     }
 
     }
 
-    my $digest = Koha::AuthUtils::hash_password($password);
-    $self->update(
-        {
-            password       => $digest,
-            login_attempts => 0,
+    if ( C4::Context->preference('UseKohaPlugins') && C4::Context->config("enable_plugins") ) {
+        # Call any check_password plugins
+        my @plugins = Koha::Plugins->new()->GetPlugins({
+            method => 'check_password',
+        });
+        foreach my $plugin ( @plugins ) {
+            # This plugin hook will also be used by a plugin for the Norwegian national
+            # patron database. This is why we need to pass both the password and the
+            # borrowernumber to the plugin.
+            my $ret = $plugin->check_password(
+                {
+                    password       => $password,
+                    borrowernumber => $self->borrowernumber
+                }
+            );
+            # This plugin hook will also be used by a plugin for the Norwegian national
+            # patron database. This is why we need to call the actual plugins and then
+            # check skip_validation afterwards.
+            if ( $ret->{'error'} == 1 && !$args->{skip_validation} ) {
+                Koha::Exceptions::Password::Plugin->throw();
+            }
         }
         }
-    );
+    }
+
+    my $digest = Koha::AuthUtils::hash_password($password);
+
+    # We do not want to call $self->store and retrieve password from DB
+    $self->password($digest);
+    $self->login_attempts(0);
+    $self->SUPER::store;
+
+    logaction( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" )
+        if C4::Context->preference("BorrowersLog");
 
 
-    logaction( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" ) if C4::Context->preference("BorrowersLog");
-    return $digest;
+    return $self;
 }
 
 }
 
+
 =head3 renew_account
 
 my $new_expiry_date = $patron->renew_account
 =head3 renew_account
 
 my $new_expiry_date = $patron->renew_account
@@ -722,7 +811,7 @@ sub renew_account {
     $self->date_renewed( dt_from_string() );
     $self->store();
 
     $self->date_renewed( dt_from_string() );
     $self->store();
 
-    $self->add_enrolment_fee_if_needed;
+    $self->add_enrolment_fee_if_needed(1);
 
     logaction( "MEMBERS", "RENEW", $self->borrowernumber, "Membership renewed" ) if C4::Context->preference("BorrowersLog");
     return dt_from_string( $expiry_date )->truncate( to => 'day' );
 
     logaction( "MEMBERS", "RENEW", $self->borrowernumber, "Membership renewed" ) if C4::Context->preference("BorrowersLog");
     return dt_from_string( $expiry_date )->truncate( to => 'day' );
@@ -846,18 +935,28 @@ sub article_requests_finished {
 
 =head3 add_enrolment_fee_if_needed
 
 
 =head3 add_enrolment_fee_if_needed
 
-my $enrolment_fee = $patron->add_enrolment_fee_if_needed;
+my $enrolment_fee = $patron->add_enrolment_fee_if_needed($renewal);
 
 Add enrolment fee for a patron if needed.
 
 
 Add enrolment fee for a patron if needed.
 
+$renewal - boolean denoting whether this is an account renewal or not
+
 =cut
 
 sub add_enrolment_fee_if_needed {
 =cut
 
 sub add_enrolment_fee_if_needed {
-    my ($self) = @_;
+    my ($self, $renewal) = @_;
     my $enrolment_fee = $self->category->enrolmentfee;
     if ( $enrolment_fee && $enrolment_fee > 0 ) {
     my $enrolment_fee = $self->category->enrolmentfee;
     if ( $enrolment_fee && $enrolment_fee > 0 ) {
-        # insert fee in patron debts
-        C4::Accounts::manualinvoice( $self->borrowernumber, '', '', 'A', $enrolment_fee );
+        my $type = $renewal ? 'ACCOUNT_RENEW' : 'ACCOUNT';
+        $self->account->add_debit(
+            {
+                amount     => $enrolment_fee,
+                user_id    => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
+                interface  => C4::Context->interface,
+                library_id => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
+                type       => $type
+            }
+        );
     }
     return $enrolment_fee || 0;
 }
     }
     return $enrolment_fee || 0;
 }
@@ -886,7 +985,6 @@ It has been introduced to replaced the C4::Members::GetPendingIssues subroutine
 It should not be used directly, prefer to access fields you need instead of
 retrieving all these fields in one go.
 
 It should not be used directly, prefer to access fields you need instead of
 retrieving all these fields in one go.
 
-
 =cut
 
 sub pending_checkouts {
 =cut
 
 sub pending_checkouts {
@@ -977,6 +1075,24 @@ sub get_age {
     return $age;
 }
 
     return $age;
 }
 
+=head3 is_valid_age
+
+my $is_valid = $patron->is_valid_age
+
+Return 1 if patron's age is between allowed limits, returns 0 if it's not.
+
+=cut
+
+sub is_valid_age {
+    my ($self) = @_;
+    my $age = $self->get_age;
+
+    my $patroncategory = $self->category;
+    my ($low,$high) = ($patroncategory->dateofbirthrequired, $patroncategory->upperagelimit);
+
+    return (defined($age) && (($high && ($age > $high)) or ($age < $low))) ? 0 : 1;
+}
+
 =head3 account
 
 my $account = $patron->account
 =head3 account
 
 my $account = $patron->account
@@ -1016,6 +1132,18 @@ sub old_holds {
     return Koha::Old::Holds->_new_from_dbic($old_holds_rs);
 }
 
     return Koha::Old::Holds->_new_from_dbic($old_holds_rs);
 }
 
+=head3 return_claims
+
+my $return_claims = $patron->return_claims
+
+=cut
+
+sub return_claims {
+    my ($self) = @_;
+    my $return_claims = $self->_result->return_claims_borrowernumbers;
+    return Koha::Checkouts::ReturnClaims->_new_from_dbic( $return_claims );
+}
+
 =head3 notice_email_address
 
   my $email = $patron->notice_email_address;
 =head3 notice_email_address
 
   my $email = $patron->notice_email_address;
@@ -1092,18 +1220,24 @@ sub get_enrollable_clubs {
 
 my $is_locked = $patron->account_locked
 
 
 my $is_locked = $patron->account_locked
 
-Return true if the patron has reach the maximum number of login attempts (see pref FailedLoginAttempts).
+Return true if the patron has reached the maximum number of login attempts
+(see pref FailedLoginAttempts). If login_attempts is < 0, this is interpreted
+as an administrative lockout (independent of FailedLoginAttempts; see also
+Koha::Patron->lock).
 Otherwise return false.
 Otherwise return false.
-If the pref is not set (empty string, null or 0), the feature is considered as disabled.
+If the pref is not set (empty string, null or 0), the feature is considered as
+disabled.
 
 =cut
 
 sub account_locked {
     my ($self) = @_;
     my $FailedLoginAttempts = C4::Context->preference('FailedLoginAttempts');
 
 =cut
 
 sub account_locked {
     my ($self) = @_;
     my $FailedLoginAttempts = C4::Context->preference('FailedLoginAttempts');
-    return ( $FailedLoginAttempts
+    return 1 if $FailedLoginAttempts
           and $self->login_attempts
           and $self->login_attempts
-          and $self->login_attempts >= $FailedLoginAttempts )? 1 : 0;
+          and $self->login_attempts >= $FailedLoginAttempts;
+    return 1 if ($self->login_attempts || 0) < 0; # administrative lockout
+    return 0;
 }
 
 =head3 can_see_patron_infos
 }
 
 =head3 can_see_patron_infos
@@ -1116,6 +1250,7 @@ Return true if the patron (usually the logged in user) can see the patron's info
 
 sub can_see_patron_infos {
     my ( $self, $patron ) = @_;
 
 sub can_see_patron_infos {
     my ( $self, $patron ) = @_;
+    return unless $patron;
     return $self->can_see_patrons_from( $patron->library->branchcode );
 }
 
     return $self->can_see_patrons_from( $patron->library->branchcode );
 }
 
@@ -1222,6 +1357,7 @@ my $is_child = $patron->is_child
 Return true if the patron has a category with a type Child (C)
 
 =cut
 Return true if the patron has a category with a type Child (C)
 
 =cut
+
 sub is_child {
     my( $self ) = @_;
     return $self->category->category_type eq 'C' ? 1 : 0;
 sub is_child {
     my( $self ) = @_;
     return $self->category->category_type eq 'C' ? 1 : 0;
@@ -1283,14 +1419,217 @@ sub generate_userid {
       $firstname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
       $surname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
       my $userid = lc(($firstname)? "$firstname.$surname" : $surname);
       $firstname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
       $surname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
       my $userid = lc(($firstname)? "$firstname.$surname" : $surname);
-      $userid = unac_string('utf-8',$userid);
+      $userid = NFKD( $userid );
+      $userid =~ s/\p{NonspacingMark}//g;
       $userid .= $offset unless $offset == 0;
       $self->userid( $userid );
       $offset++;
      } while (! $self->has_valid_userid );
 
      return $self;
       $userid .= $offset unless $offset == 0;
       $self->userid( $userid );
       $offset++;
      } while (! $self->has_valid_userid );
 
      return $self;
+}
+
+=head3 attributes
+
+my $attributes = $patron->attributes
+
+Return object of Koha::Patron::Attributes type with all attributes set for this patron
+
+=cut
+
+sub attributes {
+    my ( $self ) = @_;
+    return Koha::Patron::Attributes->search({
+        borrowernumber => $self->borrowernumber,
+        branchcode     => $self->branchcode,
+    });
+}
+
+=head3 lock
+
+    Koha::Patrons->find($id)->lock({ expire => 1, remove => 1 });
 
 
+    Lock and optionally expire a patron account.
+    Remove holds and article requests if remove flag set.
+    In order to distinguish from locking by entering a wrong password, let's
+    call this an administrative lockout.
+
+=cut
+
+sub lock {
+    my ( $self, $params ) = @_;
+    $self->login_attempts( ADMINISTRATIVE_LOCKOUT );
+    if( $params->{expire} ) {
+        $self->dateexpiry( dt_from_string->subtract(days => 1) );
+    }
+    $self->store;
+    if( $params->{remove} ) {
+        $self->holds->delete;
+        $self->article_requests->delete;
+    }
+    return $self;
+}
+
+=head3 anonymize
+
+    Koha::Patrons->find($id)->anonymize;
+
+    Anonymize or clear borrower fields. Fields in BorrowerMandatoryField
+    are randomized, other personal data is cleared too.
+    Patrons with issues are skipped.
+
+=cut
+
+sub anonymize {
+    my ( $self ) = @_;
+    if( $self->_result->issues->count ) {
+        warn "Exiting anonymize: patron ".$self->borrowernumber." still has issues";
+        return;
+    }
+    # Mandatory fields come from the corresponding pref, but email fields
+    # are removed since scrambled email addresses only generate errors
+    my $mandatory = { map { (lc $_, 1); } grep { !/email/ }
+        split /\s*\|\s*/, C4::Context->preference('BorrowerMandatoryField') };
+    $mandatory->{userid} = 1; # needed since sub store does not clear field
+    my @columns = $self->_result->result_source->columns;
+    @columns = grep { !/borrowernumber|branchcode|categorycode|^date|password|flags|updated_on|lastseen|lang|login_attempts|anonymized/ } @columns;
+    push @columns, 'dateofbirth'; # add this date back in
+    foreach my $col (@columns) {
+        $self->_anonymize_column($col, $mandatory->{lc $col} );
+    }
+    $self->anonymized(1)->store;
+}
+
+sub _anonymize_column {
+    my ( $self, $col, $mandatory ) = @_;
+    my $col_info = $self->_result->result_source->column_info($col);
+    my $type = $col_info->{data_type};
+    my $nullable = $col_info->{is_nullable};
+    my $val;
+    if( $type =~ /char|text/ ) {
+        $val = $mandatory
+            ? Koha::Token->new->generate({ pattern => '\w{10}' })
+            : $nullable
+            ? undef
+            : q{};
+    } elsif( $type =~ /integer|int$|float|dec|double/ ) {
+        $val = $nullable ? undef : 0;
+    } elsif( $type =~ /date|time/ ) {
+        $val = $nullable ? undef : dt_from_string;
+    }
+    $self->$col($val);
+}
+
+=head3 add_guarantor
+
+    my @relationships = $patron->add_guarantor(
+        {
+            borrowernumber => $borrowernumber,
+            relationships  => $relationship,
+        }
+    );
+
+    Adds a new guarantor to a patron.
+
+=cut
+
+sub add_guarantor {
+    my ( $self, $params ) = @_;
+
+    my $guarantor_id = $params->{guarantor_id};
+    my $relationship = $params->{relationship};
+
+    return Koha::Patron::Relationship->new(
+        {
+            guarantee_id => $self->id,
+            guarantor_id => $guarantor_id,
+            relationship => $relationship
+        }
+    )->store();
+}
+
+=head3 to_api
+
+    my $json = $patron->to_api;
+
+Overloaded method that returns a JSON representation of the Koha::Patron object,
+suitable for API output.
+
+=cut
+
+sub to_api {
+    my ( $self, $params ) = @_;
+
+    my $json_patron = $self->SUPER::to_api( $params );
+
+    $json_patron->{restricted} = ( $self->is_debarred )
+                                    ? Mojo::JSON->true
+                                    : Mojo::JSON->false;
+
+    return $json_patron;
+}
+
+=head3 to_api_mapping
+
+This method returns the mapping for representing a Koha::Patron object
+on the API.
+
+=cut
+
+sub to_api_mapping {
+    return {
+        borrowernotes       => 'staff_notes',
+        borrowernumber      => 'patron_id',
+        branchcode          => 'library_id',
+        categorycode        => 'category_id',
+        checkprevcheckout   => 'check_previous_checkout',
+        contactfirstname    => undef,                     # Unused
+        contactname         => undef,                     # Unused
+        contactnote         => 'altaddress_notes',
+        contacttitle        => undef,                     # Unused
+        dateenrolled        => 'date_enrolled',
+        dateexpiry          => 'expiry_date',
+        dateofbirth         => 'date_of_birth',
+        debarred            => undef,                     # replaced by 'restricted'
+        debarredcomment     => undef,    # calculated, API consumers will use /restrictions instead
+        emailpro            => 'secondary_email',
+        flags               => undef,    # permissions manipulation handled in /permissions
+        gonenoaddress       => 'incorrect_address',
+        guarantorid         => 'guarantor_id',
+        lastseen            => 'last_seen',
+        lost                => 'patron_card_lost',
+        opacnote            => 'opac_notes',
+        othernames          => 'other_name',
+        password            => undef,            # password manipulation handled in /password
+        phonepro            => 'secondary_phone',
+        relationship        => 'relationship_type',
+        sex                 => 'gender',
+        smsalertnumber      => 'sms_number',
+        sort1               => 'statistics_1',
+        sort2               => 'statistics_2',
+        streetnumber        => 'street_number',
+        streettype          => 'street_type',
+        zipcode             => 'postal_code',
+        B_address           => 'altaddress_address',
+        B_address2          => 'altaddress_address2',
+        B_city              => 'altaddress_city',
+        B_country           => 'altaddress_country',
+        B_email             => 'altaddress_email',
+        B_phone             => 'altaddress_phone',
+        B_state             => 'altaddress_state',
+        B_streetnumber      => 'altaddress_street_number',
+        B_streettype        => 'altaddress_street_type',
+        B_zipcode           => 'altaddress_postal_code',
+        altcontactaddress1  => 'altcontact_address',
+        altcontactaddress2  => 'altcontact_address2',
+        altcontactaddress3  => 'altcontact_city',
+        altcontactcountry   => 'altcontact_country',
+        altcontactfirstname => 'altcontact_firstname',
+        altcontactphone     => 'altcontact_phone',
+        altcontactsurname   => 'altcontact_surname',
+        altcontactstate     => 'altcontact_state',
+        altcontactzipcode   => 'altcontact_postal_code'
+    };
 }
 
 =head2 Internal methods
 }
 
 =head2 Internal methods
@@ -1303,10 +1642,11 @@ sub _type {
     return 'Borrower';
 }
 
     return 'Borrower';
 }
 
-=head1 AUTHOR
+=head1 AUTHORS
 
 Kyle M Hall <kyle@bywatersolutions.com>
 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
 
 Kyle M Hall <kyle@bywatersolutions.com>
 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
+Martin Renvoize <martin.renvoize@ptfs-europe.com>
 
 =cut
 
 
 =cut