Bug 21336: Search, lock and anonymize methods
authorMarcel de Rooy <m.de.rooy@rijksmuseum.nl>
Wed, 12 Sep 2018 12:25:26 +0000 (14:25 +0200)
committerNick Clemens <nick@bywatersolutions.com>
Wed, 17 Apr 2019 12:25:24 +0000 (12:25 +0000)
Add Koha::Patron->lock and anonymize.
Add Koha::Patrons methods search_unsubscribed, search_anonymize_candidates
and search_anonymized. And wrappers for lock and anonymize.
Add unit tests.

Test plan:
Run t/db_dependent/Koha/Patrons.t

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>

Signed-off-by: Josef Moravec <josef.moravec@gmail.com>
Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>

Signed-off-by: Nick Clemens <nick@bywatersolutions.com>

Koha/Patron.pm
Koha/Patrons.pm
t/db_dependent/Koha/Patrons.t

index 769324a..eedac6b 100644 (file)
@@ -44,9 +44,12 @@ use Koha::Virtualshelves;
 use Koha::Club::Enrollments;
 use Koha::Account;
 use Koha::Subscription::Routinglists;
+use Koha::Token;
 
 use base qw(Koha::Object);
 
+use constant ADMINISTRATIVE_LOCKOUT => -1;
+
 our $RESULTSET_PATRON_ID_MAPPING = {
     Accountline          => 'borrowernumber',
     Aqbasketuser         => 'borrowernumber',
@@ -1314,6 +1317,76 @@ sub attributes {
     });
 }
 
+=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;
+    }
+    my $mandatory = { map { (lc $_, 1); }
+        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|flgAnonymized/ } @columns;
+    push @columns, 'dateofbirth'; # add this date back in
+    foreach my $col (@columns) {
+        if( $mandatory->{lc $col} ) {
+            my $str = $self->_anonymize_column($col);
+            $self->$col($str);
+        } else {
+            $self->$col(undef);
+        }
+    }
+    $self->flgAnonymized(1)->store;
+}
+
+sub _anonymize_column {
+    my ( $self, $col ) = @_;
+    my $type = $self->_result->result_source->column_info($col)->{data_type};
+    if( $type =~ /char|text/ ) {
+        return Koha::Token->new->generate({ pattern => '\w{10}' });
+    } elsif( $type =~ /integer|int$|float|dec|double/ ) {
+        return 0;
+    } elsif( $type =~ /date|time/ ) {
+        return dt_from_string;
+    }
+}
+
 =head2 Internal methods
 
 =head3 _type
index dedf4c4..7da0655 100644 (file)
@@ -236,6 +236,128 @@ sub delete {
     return $patrons_deleted;
 }
 
+=head3 search_unsubscribed
+
+    Koha::Patrons->search_unsubscribed;
+
+    Returns a set of Koha patron objects for patrons that recently
+    unsubscribed and are not locked (candidates for locking).
+    Depends on UnsubscribeReflectionDelay.
+
+=cut
+
+sub search_unsubscribed {
+    my ( $class ) = @_;
+
+    my $delay = C4::Context->preference('UnsubscribeReflectionDelay');
+    if( !defined($delay) || $delay eq q{} ) {
+        # return empty set
+        return $class->search({ borrowernumber => undef });
+    }
+    my $parser = Koha::Database->new->schema->storage->datetime_parser;
+    my $dt = dt_from_string()->subtract( days => $delay );
+    my $str = $parser->format_datetime($dt);
+    my $fails = C4::Context->preference('FailedLoginAttempts') || 0;
+    my $cond = [ undef, 0, 1..$fails-1 ]; # NULL, 0, 1..fails-1 (if fails>0)
+    return $class->search(
+        {
+            'patron_consents.refused_on' => { '<=' => $str },
+            'login_attempts' => $cond,
+        },
+        { join => 'patron_consents' },
+    );
+}
+
+=head3 search_anonymize_candidates
+
+    Koha::Patrons->search_anonymize_candidates({ locked => 1 });
+
+    Returns a set of Koha patron objects for patrons whose account is expired
+    and locked (if parameter set). These are candidates for anonymizing.
+    Depends on PatronAnonymizeDelay.
+
+=cut
+
+sub search_anonymize_candidates {
+    my ( $class, $params ) = @_;
+
+    my $delay = C4::Context->preference('PatronAnonymizeDelay');
+    if( !defined($delay) || $delay eq q{} ) {
+        # return empty set
+        return $class->search({ borrowernumber => undef });
+    }
+    my $cond = {};
+    my $parser = Koha::Database->new->schema->storage->datetime_parser;
+    my $dt = dt_from_string()->subtract( days => $delay );
+    my $str = $parser->format_datetime($dt);
+    $cond->{dateexpiry} = { '<=' => $str };
+    $cond->{flgAnonymized} = [ undef, 0 ]; # not yet done
+    if( $params->{locked} ) {
+        my $fails = C4::Context->preference('FailedLoginAttempts');
+        $cond->{login_attempts} = [ -and => { '!=' => undef }, { -not_in => [0, 1..$fails-1 ] } ]; # -not_in does not like undef
+    }
+    return $class->search( $cond );
+}
+
+=head3 search_anonymized
+
+    Koha::Patrons->search_anonymized;
+
+    Returns a set of Koha patron objects for patron accounts that have been
+    anonymized before and could be removed.
+    Depends on PatronRemovalDelay.
+
+=cut
+
+sub search_anonymized {
+    my ( $class ) = @_;
+
+    my $delay = C4::Context->preference('PatronRemovalDelay');
+    if( !defined($delay) || $delay eq q{} ) {
+        # return empty set
+        return $class->search({ borrowernumber => undef });
+    }
+    my $cond = {};
+    my $parser = Koha::Database->new->schema->storage->datetime_parser;
+    my $dt = dt_from_string()->subtract( days => $delay );
+    my $str = $parser->format_datetime($dt);
+    $cond->{dateexpiry} = { '<=' => $str };
+    $cond->{flgAnonymized} = 1;
+    return $class->search( $cond );
+}
+
+=head3 lock
+
+    Koha::Patrons->search({ some filters })->lock({ expire => 1, remove => 1 })
+
+    Lock the passed set of patron objects. Optionally expire and remove holds.
+    Wrapper around Koha::Patron->lock.
+
+=cut
+
+sub lock {
+    my ( $self, $params ) = @_;
+    while( my $patron = $self->next ) {
+        $patron->lock($params);
+    }
+}
+
+=head3 anonymize
+
+    Koha::Patrons->search({ some filters })->anonymize;
+
+    Anonymize passed set of patron objects.
+    Wrapper around Koha::Patron->anonymize.
+
+=cut
+
+sub anonymize {
+    my ( $self ) = @_;
+    while( my $patron = $self->next ) {
+        $patron->anonymize;
+    }
+}
+
 =head3 _type
 
 =cut
index 51f62db..6acff6c 100644 (file)
@@ -19,7 +19,7 @@
 
 use Modern::Perl;
 
-use Test::More tests => 34;
+use Test::More tests => 39;
 use Test::Warn;
 use Test::Exception;
 use Test::MockModule;
@@ -1625,3 +1625,165 @@ subtest '->set_password' => sub {
 
     $schema->storage->txn_rollback;
 };
+
+$schema->storage->txn_begin;
+subtest 'search_unsubscribed' => sub {
+    plan tests => 4;
+
+    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 3 );
+    t::lib::Mocks::mock_preference( 'UnsubscribeReflectionDelay', '' );
+    is( Koha::Patrons->search_unsubscribed->count, 0, 'Empty delay should return empty set' );
+
+    my $patron1 = $builder->build_object({ class => 'Koha::Patrons' });
+    my $patron2 = $builder->build_object({ class => 'Koha::Patrons' });
+
+    t::lib::Mocks::mock_preference( 'UnsubscribeReflectionDelay', 0 );
+    Koha::Patron::Consents->delete; # for correct counts
+    Koha::Patron::Consent->new({ borrowernumber => $patron1->borrowernumber, type => 'GDPR_PROCESSING',  refused_on => dt_from_string })->store;
+    is( Koha::Patrons->search_unsubscribed->count, 1, 'Find patron1' );
+
+    # Add another refusal but shift the period
+    t::lib::Mocks::mock_preference( 'UnsubscribeReflectionDelay', 2 );
+    Koha::Patron::Consent->new({ borrowernumber => $patron2->borrowernumber, type => 'GDPR_PROCESSING',  refused_on => dt_from_string->subtract(days=>2) })->store;
+    is( Koha::Patrons->search_unsubscribed->count, 1, 'Find patron2 only' );
+
+    # Try another (special) attempts setting
+    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 0 );
+    # Lockout is now disabled
+    # Patron2 still matches: refused earlier, not locked
+    is( Koha::Patrons->search_unsubscribed->count, 1, 'Lockout disabled' );
+};
+
+subtest 'search_anonymize_candidates' => sub {
+    plan tests => 5;
+    my $patron1 = $builder->build_object({ class => 'Koha::Patrons' });
+    my $patron2 = $builder->build_object({ class => 'Koha::Patrons' });
+    $patron1->flgAnonymized(0);
+    $patron1->dateexpiry( dt_from_string->add(days => 1) )->store;
+    $patron2->flgAnonymized(undef);
+    $patron2->dateexpiry( dt_from_string->add(days => 1) )->store;
+
+    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', q{} );
+    is( Koha::Patrons->search_anonymize_candidates->count, 0, 'Empty set' );
+
+    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', 0 );
+    my $cnt = Koha::Patrons->search_anonymize_candidates->count;
+    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
+    $patron2->dateexpiry( dt_from_string->subtract(days => 3) )->store;
+    is( Koha::Patrons->search_anonymize_candidates->count, $cnt+2, 'Delay 0' );
+
+    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', 2 );
+    $patron1->dateexpiry( dt_from_string->add(days => 1) )->store;
+    $patron2->dateexpiry( dt_from_string->add(days => 1) )->store;
+    $cnt = Koha::Patrons->search_anonymize_candidates->count;
+    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
+    $patron2->dateexpiry( dt_from_string->subtract(days => 3) )->store;
+    is( Koha::Patrons->search_anonymize_candidates->count, $cnt+1, 'Delay 2' );
+
+    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', 4 );
+    $patron1->dateexpiry( dt_from_string->add(days => 1) )->store;
+    $patron2->dateexpiry( dt_from_string->add(days => 1) )->store;
+    $cnt = Koha::Patrons->search_anonymize_candidates->count;
+    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
+    $patron2->dateexpiry( dt_from_string->subtract(days => 3) )->store;
+    is( Koha::Patrons->search_anonymize_candidates->count, $cnt, 'Delay 4' );
+
+    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 3 );
+    $patron1->dateexpiry( dt_from_string->subtract(days => 5) )->store;
+    $patron1->login_attempts(0)->store;
+    $patron2->dateexpiry( dt_from_string->subtract(days => 5) )->store;
+    $patron2->login_attempts(0)->store;
+    $cnt = Koha::Patrons->search_anonymize_candidates({locked => 1})->count;
+    $patron1->login_attempts(3)->store;
+    is( Koha::Patrons->search_anonymize_candidates({locked => 1})->count,
+        $cnt+1, 'Locked flag' );
+};
+
+subtest 'search_anonymized' => sub {
+    plan tests => 3;
+    my $patron1 = $builder->build_object( { class => 'Koha::Patrons' } );
+
+    t::lib::Mocks::mock_preference( 'PatronRemovalDelay', q{} );
+    is( Koha::Patrons->search_anonymized->count, 0, 'Empty set' );
+
+    t::lib::Mocks::mock_preference( 'PatronRemovalDelay', 1 );
+    $patron1->dateexpiry( dt_from_string );
+    $patron1->flgAnonymized(0)->store;
+    my $cnt = Koha::Patrons->search_anonymized->count;
+    $patron1->flgAnonymized(1)->store;
+    is( Koha::Patrons->search_anonymized->count, $cnt, 'Number unchanged' );
+    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
+    is( Koha::Patrons->search_anonymized->count, $cnt+1, 'Found patron1' );
+};
+
+subtest 'lock' => sub {
+    plan tests => 8;
+
+    my $patron1 = $builder->build_object( { class => 'Koha::Patrons' } );
+    my $patron2 = $builder->build_object( { class => 'Koha::Patrons' } );
+    my $hold = $builder->build_object({
+        class => 'Koha::Holds',
+        value => { borrowernumber => $patron1->borrowernumber },
+    });
+
+    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 3 );
+    my $expiry = dt_from_string->add(days => 1);
+    $patron1->dateexpiry( $expiry );
+    $patron1->lock;
+    is( $patron1->login_attempts, Koha::Patron::ADMINISTRATIVE_LOCKOUT, 'Check login_attempts' );
+    is( $patron1->dateexpiry, $expiry, 'Not expired yet' );
+    is( $patron1->holds->count, 1, 'No holds removed' );
+
+    $patron1->lock({ expire => 1, remove => 1});
+    isnt( $patron1->dateexpiry, $expiry, 'Expiry date adjusted' );
+    is( $patron1->holds->count, 0, 'Holds removed' );
+
+    # Disable lockout feature
+    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', q{} );
+    $patron1->login_attempts(0);
+    $patron1->dateexpiry( $expiry );
+    $patron1->store;
+    $patron1->lock;
+    is( $patron1->login_attempts, Koha::Patron::ADMINISTRATIVE_LOCKOUT, 'Check login_attempts' );
+
+    # Trivial wrapper test (Koha::Patrons->lock)
+    $patron1->login_attempts(0)->store;
+    Koha::Patrons->search({ borrowernumber => [ $patron1->borrowernumber, $patron2->borrowernumber ] })->lock;
+    $patron1->discard_changes; # refresh
+    $patron2->discard_changes;
+    is( $patron1->login_attempts, Koha::Patron::ADMINISTRATIVE_LOCKOUT, 'Check login_attempts patron 1' );
+    is( $patron2->login_attempts, Koha::Patron::ADMINISTRATIVE_LOCKOUT, 'Check login_attempts patron 2' );
+};
+
+subtest 'anonymize' => sub {
+    plan tests => 9;
+
+    my $patron1 = $builder->build_object( { class => 'Koha::Patrons' } );
+    my $patron2 = $builder->build_object( { class => 'Koha::Patrons' } );
+
+    # First try patron with issues
+    my $issue = $builder->build_object({ class => 'Koha::Checkouts', value => { borrowernumber => $patron2->borrowernumber } });
+    warning_like { $patron2->anonymize } qr/still has issues/, 'Skip patron with issues';
+    $issue->delete;
+
+    t::lib::Mocks::mock_preference( 'BorrowerMandatoryField', 'surname|email|cardnumber' );
+    my $surname = $patron1->surname; # expect change, no clear
+    my $branchcode = $patron1->branchcode; # expect skip
+    $patron1->anonymize;
+    is($patron1->flgAnonymized, 1, 'Check flag' );
+
+    is( $patron1->dateofbirth, undef, 'Birth date cleared' );
+    is( $patron1->firstname, undef, 'First name cleared' );
+    isnt( $patron1->surname, $surname, 'Surname changed' );
+    ok( $patron1->surname =~ /^\w{10}$/, 'Mandatory surname randomized' );
+    is( $patron1->branchcode, $branchcode, 'Branch code skipped' );
+
+    # Test wrapper in Koha::Patrons
+    $patron1->surname($surname)->store; # restore
+    my $rs = Koha::Patrons->search({ borrowernumber => [ $patron1->borrowernumber, $patron2->borrowernumber ] })->anonymize;
+    $patron1->discard_changes; # refresh
+    isnt( $patron1->surname, $surname, 'Surname patron1 changed again' );
+    $patron2->discard_changes; # refresh
+    is( $patron2->firstname, undef, 'First name patron2 cleared' );
+};
+$schema->storage->txn_rollback;