View | Details | Raw Unified | Return to bug 21336
Collapse All | Expand All

(-)a/Koha/Patron.pm (+73 lines)
Lines 44-52 use Koha::Virtualshelves; Link Here
44
use Koha::Club::Enrollments;
44
use Koha::Club::Enrollments;
45
use Koha::Account;
45
use Koha::Account;
46
use Koha::Subscription::Routinglists;
46
use Koha::Subscription::Routinglists;
47
use Koha::Token;
47
48
48
use base qw(Koha::Object);
49
use base qw(Koha::Object);
49
50
51
use constant ADMINISTRATIVE_LOCKOUT => -1;
52
50
our $RESULTSET_PATRON_ID_MAPPING = {
53
our $RESULTSET_PATRON_ID_MAPPING = {
51
    Accountline          => 'borrowernumber',
54
    Accountline          => 'borrowernumber',
52
    Aqbasketuser         => 'borrowernumber',
55
    Aqbasketuser         => 'borrowernumber',
Lines 1313-1318 sub attributes { Link Here
1313
    });
1316
    });
1314
}
1317
}
1315
1318
1319
=head3 lock
1320
1321
    Koha::Patrons->find($id)->lock({ expire => 1, remove => 1 });
1322
1323
    Lock and optionally expire a patron account.
1324
    Remove holds and article requests if remove flag set.
1325
    In order to distinguish from locking by entering a wrong password, let's
1326
    call this an administrative lockout.
1327
1328
=cut
1329
1330
sub lock {
1331
    my ( $self, $params ) = @_;
1332
    $self->login_attempts( ADMINISTRATIVE_LOCKOUT );
1333
    if( $params->{expire} ) {
1334
        $self->dateexpiry( dt_from_string->subtract(days => 1) );
1335
    }
1336
    $self->store;
1337
    if( $params->{remove} ) {
1338
        $self->holds->delete;
1339
        $self->article_requests->delete;
1340
    }
1341
    return $self;
1342
}
1343
1344
=head3 anonymize
1345
1346
    Koha::Patrons->find($id)->anonymize;
1347
1348
    Anonymize or clear borrower fields. Fields in BorrowerMandatoryField
1349
    are randomized, other personal data is cleared too.
1350
    Patrons with issues are skipped.
1351
1352
=cut
1353
1354
sub anonymize {
1355
    my ( $self ) = @_;
1356
    if( $self->_result->issues->count ) {
1357
        warn "Exiting anonymize: patron ".$self->borrowernumber." still has issues";
1358
        return;
1359
    }
1360
    my $mandatory = { map { (lc $_, 1); }
1361
        split /\s*\|\s*/, C4::Context->preference('BorrowerMandatoryField') };
1362
    $mandatory->{userid} = 1; # needed since sub store does not clear field
1363
    my @columns = $self->_result->result_source->columns;
1364
    @columns = grep { !/borrowernumber|branchcode|categorycode|^date|password|flags|updated_on|lastseen|lang|login_attempts|flgAnonymized/ } @columns;
1365
    push @columns, 'dateofbirth'; # add this date back in
1366
    foreach my $col (@columns) {
1367
        if( $mandatory->{lc $col} ) {
1368
            my $str = $self->_anonymize_column($col);
1369
            $self->$col($str);
1370
        } else {
1371
            $self->$col(undef);
1372
        }
1373
    }
1374
    $self->flgAnonymized(1)->store;
1375
}
1376
1377
sub _anonymize_column {
1378
    my ( $self, $col ) = @_;
1379
    my $type = $self->_result->result_source->column_info($col)->{data_type};
1380
    if( $type =~ /char|text/ ) {
1381
        return Koha::Token->new->generate({ pattern => '\w{10}' });
1382
    } elsif( $type =~ /integer|int$|float|dec|double/ ) {
1383
        return 0;
1384
    } elsif( $type =~ /date|time/ ) {
1385
        return dt_from_string;
1386
    }
1387
}
1388
1316
=head2 Internal methods
1389
=head2 Internal methods
1317
1390
1318
=head3 _type
1391
=head3 _type
(-)a/Koha/Patrons.pm (+122 lines)
Lines 236-241 sub delete { Link Here
236
    return $patrons_deleted;
236
    return $patrons_deleted;
237
}
237
}
238
238
239
=head3 search_unsubscribed
240
241
    Koha::Patrons->search_unsubscribed;
242
243
    Returns a set of Koha patron objects for patrons that recently
244
    unsubscribed and are not locked (candidates for locking).
245
    Depends on UnsubscribeReflectionDelay.
246
247
=cut
248
249
sub search_unsubscribed {
250
    my ( $class ) = @_;
251
252
    my $delay = C4::Context->preference('UnsubscribeReflectionDelay');
253
    if( !defined($delay) || $delay eq q{} ) {
254
        # return empty set
255
        return $class->search({ borrowernumber => undef });
256
    }
257
    my $parser = Koha::Database->new->schema->storage->datetime_parser;
258
    my $dt = dt_from_string()->subtract( days => $delay );
259
    my $str = $parser->format_datetime($dt);
260
    my $fails = C4::Context->preference('FailedLoginAttempts') || 0;
261
    my $cond = [ undef, 0, 1..$fails-1 ]; # NULL, 0, 1..fails-1 (if fails>0)
262
    return $class->search(
263
        {
264
            'patron_consents.refused_on' => { '<=' => $str },
265
            'login_attempts' => $cond,
266
        },
267
        { join => 'patron_consents' },
268
    );
269
}
270
271
=head3 search_anonymize_candidates
272
273
    Koha::Patrons->search_anonymize_candidates({ locked => 1 });
274
275
    Returns a set of Koha patron objects for patrons whose account is expired
276
    and locked (if parameter set). These are candidates for anonymizing.
277
    Depends on PatronAnonymizeDelay.
278
279
=cut
280
281
sub search_anonymize_candidates {
282
    my ( $class, $params ) = @_;
283
284
    my $delay = C4::Context->preference('PatronAnonymizeDelay');
285
    if( !defined($delay) || $delay eq q{} ) {
286
        # return empty set
287
        return $class->search({ borrowernumber => undef });
288
    }
289
    my $cond = {};
290
    my $parser = Koha::Database->new->schema->storage->datetime_parser;
291
    my $dt = dt_from_string()->subtract( days => $delay );
292
    my $str = $parser->format_datetime($dt);
293
    $cond->{dateexpiry} = { '<=' => $str };
294
    $cond->{flgAnonymized} = [ undef, 0 ]; # not yet done
295
    if( $params->{locked} ) {
296
        my $fails = C4::Context->preference('FailedLoginAttempts');
297
        $cond->{login_attempts} = [ -and => { '!=' => undef }, { -not_in => [0, 1..$fails-1 ] } ]; # -not_in does not like undef
298
    }
299
    return $class->search( $cond );
300
}
301
302
=head3 search_anonymized
303
304
    Koha::Patrons->search_anonymized;
305
306
    Returns a set of Koha patron objects for patron accounts that have been
307
    anonymized before and could be removed.
308
    Depends on PatronRemovalDelay.
309
310
=cut
311
312
sub search_anonymized {
313
    my ( $class ) = @_;
314
315
    my $delay = C4::Context->preference('PatronRemovalDelay');
316
    if( !defined($delay) || $delay eq q{} ) {
317
        # return empty set
318
        return $class->search({ borrowernumber => undef });
319
    }
320
    my $cond = {};
321
    my $parser = Koha::Database->new->schema->storage->datetime_parser;
322
    my $dt = dt_from_string()->subtract( days => $delay );
323
    my $str = $parser->format_datetime($dt);
324
    $cond->{dateexpiry} = { '<=' => $str };
325
    $cond->{flgAnonymized} = 1;
326
    return $class->search( $cond );
327
}
328
329
=head3 lock
330
331
    Koha::Patrons->search({ some filters })->lock({ expire => 1, remove => 1 })
332
333
    Lock the passed set of patron objects. Optionally expire and remove holds.
334
    Wrapper around Koha::Patron->lock.
335
336
=cut
337
338
sub lock {
339
    my ( $self, $params ) = @_;
340
    while( my $patron = $self->next ) {
341
        $patron->lock($params);
342
    }
343
}
344
345
=head3 anonymize
346
347
    Koha::Patrons->search({ some filters })->anonymize;
348
349
    Anonymize passed set of patron objects.
350
    Wrapper around Koha::Patron->anonymize.
351
352
=cut
353
354
sub anonymize {
355
    my ( $self ) = @_;
356
    while( my $patron = $self->next ) {
357
        $patron->anonymize;
358
    }
359
}
360
239
=head3 _type
361
=head3 _type
240
362
241
=cut
363
=cut
(-)a/t/db_dependent/Koha/Patrons.t (-2 / +163 lines)
Lines 19-25 Link Here
19
19
20
use Modern::Perl;
20
use Modern::Perl;
21
21
22
use Test::More tests => 34;
22
use Test::More tests => 39;
23
use Test::Warn;
23
use Test::Warn;
24
use Test::Exception;
24
use Test::Exception;
25
use Test::MockModule;
25
use Test::MockModule;
Lines 1625-1627 subtest '->set_password' => sub { Link Here
1625
1625
1626
    $schema->storage->txn_rollback;
1626
    $schema->storage->txn_rollback;
1627
};
1627
};
1628
- 
1628
1629
$schema->storage->txn_begin;
1630
subtest 'search_unsubscribed' => sub {
1631
    plan tests => 4;
1632
1633
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 3 );
1634
    t::lib::Mocks::mock_preference( 'UnsubscribeReflectionDelay', '' );
1635
    is( Koha::Patrons->search_unsubscribed->count, 0, 'Empty delay should return empty set' );
1636
1637
    my $patron1 = $builder->build_object({ class => 'Koha::Patrons' });
1638
    my $patron2 = $builder->build_object({ class => 'Koha::Patrons' });
1639
1640
    t::lib::Mocks::mock_preference( 'UnsubscribeReflectionDelay', 0 );
1641
    Koha::Patron::Consents->delete; # for correct counts
1642
    Koha::Patron::Consent->new({ borrowernumber => $patron1->borrowernumber, type => 'GDPR_PROCESSING',  refused_on => dt_from_string })->store;
1643
    is( Koha::Patrons->search_unsubscribed->count, 1, 'Find patron1' );
1644
1645
    # Add another refusal but shift the period
1646
    t::lib::Mocks::mock_preference( 'UnsubscribeReflectionDelay', 2 );
1647
    Koha::Patron::Consent->new({ borrowernumber => $patron2->borrowernumber, type => 'GDPR_PROCESSING',  refused_on => dt_from_string->subtract(days=>2) })->store;
1648
    is( Koha::Patrons->search_unsubscribed->count, 1, 'Find patron2 only' );
1649
1650
    # Try another (special) attempts setting
1651
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 0 );
1652
    # Lockout is now disabled
1653
    # Patron2 still matches: refused earlier, not locked
1654
    is( Koha::Patrons->search_unsubscribed->count, 1, 'Lockout disabled' );
1655
};
1656
1657
subtest 'search_anonymize_candidates' => sub {
1658
    plan tests => 5;
1659
    my $patron1 = $builder->build_object({ class => 'Koha::Patrons' });
1660
    my $patron2 = $builder->build_object({ class => 'Koha::Patrons' });
1661
    $patron1->flgAnonymized(0);
1662
    $patron1->dateexpiry( dt_from_string->add(days => 1) )->store;
1663
    $patron2->flgAnonymized(undef);
1664
    $patron2->dateexpiry( dt_from_string->add(days => 1) )->store;
1665
1666
    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', q{} );
1667
    is( Koha::Patrons->search_anonymize_candidates->count, 0, 'Empty set' );
1668
1669
    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', 0 );
1670
    my $cnt = Koha::Patrons->search_anonymize_candidates->count;
1671
    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
1672
    $patron2->dateexpiry( dt_from_string->subtract(days => 3) )->store;
1673
    is( Koha::Patrons->search_anonymize_candidates->count, $cnt+2, 'Delay 0' );
1674
1675
    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', 2 );
1676
    $patron1->dateexpiry( dt_from_string->add(days => 1) )->store;
1677
    $patron2->dateexpiry( dt_from_string->add(days => 1) )->store;
1678
    $cnt = Koha::Patrons->search_anonymize_candidates->count;
1679
    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
1680
    $patron2->dateexpiry( dt_from_string->subtract(days => 3) )->store;
1681
    is( Koha::Patrons->search_anonymize_candidates->count, $cnt+1, 'Delay 2' );
1682
1683
    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', 4 );
1684
    $patron1->dateexpiry( dt_from_string->add(days => 1) )->store;
1685
    $patron2->dateexpiry( dt_from_string->add(days => 1) )->store;
1686
    $cnt = Koha::Patrons->search_anonymize_candidates->count;
1687
    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
1688
    $patron2->dateexpiry( dt_from_string->subtract(days => 3) )->store;
1689
    is( Koha::Patrons->search_anonymize_candidates->count, $cnt, 'Delay 4' );
1690
1691
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 3 );
1692
    $patron1->dateexpiry( dt_from_string->subtract(days => 5) )->store;
1693
    $patron1->login_attempts(0)->store;
1694
    $patron2->dateexpiry( dt_from_string->subtract(days => 5) )->store;
1695
    $patron2->login_attempts(0)->store;
1696
    $cnt = Koha::Patrons->search_anonymize_candidates({locked => 1})->count;
1697
    $patron1->login_attempts(3)->store;
1698
    is( Koha::Patrons->search_anonymize_candidates({locked => 1})->count,
1699
        $cnt+1, 'Locked flag' );
1700
};
1701
1702
subtest 'search_anonymized' => sub {
1703
    plan tests => 3;
1704
    my $patron1 = $builder->build_object( { class => 'Koha::Patrons' } );
1705
1706
    t::lib::Mocks::mock_preference( 'PatronRemovalDelay', q{} );
1707
    is( Koha::Patrons->search_anonymized->count, 0, 'Empty set' );
1708
1709
    t::lib::Mocks::mock_preference( 'PatronRemovalDelay', 1 );
1710
    $patron1->dateexpiry( dt_from_string );
1711
    $patron1->flgAnonymized(0)->store;
1712
    my $cnt = Koha::Patrons->search_anonymized->count;
1713
    $patron1->flgAnonymized(1)->store;
1714
    is( Koha::Patrons->search_anonymized->count, $cnt, 'Number unchanged' );
1715
    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
1716
    is( Koha::Patrons->search_anonymized->count, $cnt+1, 'Found patron1' );
1717
};
1718
1719
subtest 'lock' => sub {
1720
    plan tests => 8;
1721
1722
    my $patron1 = $builder->build_object( { class => 'Koha::Patrons' } );
1723
    my $patron2 = $builder->build_object( { class => 'Koha::Patrons' } );
1724
    my $hold = $builder->build_object({
1725
        class => 'Koha::Holds',
1726
        value => { borrowernumber => $patron1->borrowernumber },
1727
    });
1728
1729
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 3 );
1730
    my $expiry = dt_from_string->add(days => 1);
1731
    $patron1->dateexpiry( $expiry );
1732
    $patron1->lock;
1733
    is( $patron1->login_attempts, Koha::Patron::ADMINISTRATIVE_LOCKOUT, 'Check login_attempts' );
1734
    is( $patron1->dateexpiry, $expiry, 'Not expired yet' );
1735
    is( $patron1->holds->count, 1, 'No holds removed' );
1736
1737
    $patron1->lock({ expire => 1, remove => 1});
1738
    isnt( $patron1->dateexpiry, $expiry, 'Expiry date adjusted' );
1739
    is( $patron1->holds->count, 0, 'Holds removed' );
1740
1741
    # Disable lockout feature
1742
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', q{} );
1743
    $patron1->login_attempts(0);
1744
    $patron1->dateexpiry( $expiry );
1745
    $patron1->store;
1746
    $patron1->lock;
1747
    is( $patron1->login_attempts, Koha::Patron::ADMINISTRATIVE_LOCKOUT, 'Check login_attempts' );
1748
1749
    # Trivial wrapper test (Koha::Patrons->lock)
1750
    $patron1->login_attempts(0)->store;
1751
    Koha::Patrons->search({ borrowernumber => [ $patron1->borrowernumber, $patron2->borrowernumber ] })->lock;
1752
    $patron1->discard_changes; # refresh
1753
    $patron2->discard_changes;
1754
    is( $patron1->login_attempts, Koha::Patron::ADMINISTRATIVE_LOCKOUT, 'Check login_attempts patron 1' );
1755
    is( $patron2->login_attempts, Koha::Patron::ADMINISTRATIVE_LOCKOUT, 'Check login_attempts patron 2' );
1756
};
1757
1758
subtest 'anonymize' => sub {
1759
    plan tests => 9;
1760
1761
    my $patron1 = $builder->build_object( { class => 'Koha::Patrons' } );
1762
    my $patron2 = $builder->build_object( { class => 'Koha::Patrons' } );
1763
1764
    # First try patron with issues
1765
    my $issue = $builder->build_object({ class => 'Koha::Checkouts', value => { borrowernumber => $patron2->borrowernumber } });
1766
    warning_like { $patron2->anonymize } qr/still has issues/, 'Skip patron with issues';
1767
    $issue->delete;
1768
1769
    t::lib::Mocks::mock_preference( 'BorrowerMandatoryField', 'surname|email|cardnumber' );
1770
    my $surname = $patron1->surname; # expect change, no clear
1771
    my $branchcode = $patron1->branchcode; # expect skip
1772
    $patron1->anonymize;
1773
    is($patron1->flgAnonymized, 1, 'Check flag' );
1774
1775
    is( $patron1->dateofbirth, undef, 'Birth date cleared' );
1776
    is( $patron1->firstname, undef, 'First name cleared' );
1777
    isnt( $patron1->surname, $surname, 'Surname changed' );
1778
    ok( $patron1->surname =~ /^\w{10}$/, 'Mandatory surname randomized' );
1779
    is( $patron1->branchcode, $branchcode, 'Branch code skipped' );
1780
1781
    # Test wrapper in Koha::Patrons
1782
    $patron1->surname($surname)->store; # restore
1783
    my $rs = Koha::Patrons->search({ borrowernumber => [ $patron1->borrowernumber, $patron2->borrowernumber ] })->anonymize;
1784
    $patron1->discard_changes; # refresh
1785
    isnt( $patron1->surname, $surname, 'Surname patron1 changed again' );
1786
    $patron2->discard_changes; # refresh
1787
    is( $patron2->firstname, undef, 'First name patron2 cleared' );
1788
};
1789
$schema->storage->txn_rollback;

Return to bug 21336