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

(-)a/Koha/Patron.pm (+81 lines)
Lines 43-51 use Koha::Virtualshelves; Link Here
43
use Koha::Club::Enrollments;
43
use Koha::Club::Enrollments;
44
use Koha::Account;
44
use Koha::Account;
45
use Koha::Subscription::Routinglists;
45
use Koha::Subscription::Routinglists;
46
use Koha::Token;
46
47
47
use base qw(Koha::Object);
48
use base qw(Koha::Object);
48
49
50
use constant ALT_FAILURES => 999; # signals lock when lockout is not used
51
49
our $RESULTSET_PATRON_ID_MAPPING = {
52
our $RESULTSET_PATRON_ID_MAPPING = {
50
    Accountline          => 'borrowernumber',
53
    Accountline          => 'borrowernumber',
51
    Aqbasketuser         => 'borrowernumber',
54
    Aqbasketuser         => 'borrowernumber',
Lines 1061-1066 sub account_locked { Link Here
1061
          and $self->login_attempts >= $FailedLoginAttempts )? 1 : 0;
1064
          and $self->login_attempts >= $FailedLoginAttempts )? 1 : 0;
1062
}
1065
}
1063
1066
1067
=head3 lock
1068
1069
    Koha::Patrons->find($id)->lock({ expire => 1, remove => 1 });
1070
1071
    Lock and optionally expire a patron account.
1072
    Remove holds and article requests if remove flag set.
1073
1074
    If the lockout feature is disabled, the password will be changed.
1075
1076
=cut
1077
1078
sub lock {
1079
    my ( $self, $params ) = @_;
1080
    my $fails = C4::Context->preference('FailedLoginAttempts');
1081
    if( $fails ) {
1082
        $self->login_attempts($fails);
1083
    } else {
1084
        # Set long random password
1085
        my $password = Koha::Token->new->generate({ length => 20 });
1086
        $self->update_password($self->userid, $password);
1087
        $self->login_attempts( ALT_FAILURES );
1088
    }
1089
    if( $params->{expire} ) {
1090
        $self->dateexpiry( dt_from_string->subtract(days => 1) );
1091
    }
1092
    $self->store;
1093
    if( $params->{remove} ) {
1094
        $self->holds->delete;
1095
        $self->article_requests->delete;
1096
    }
1097
    return $self;
1098
}
1099
1100
=head3 anonymize
1101
1102
    Koha::Patrons->find($id)->anonymize;
1103
1104
    Anonymize or clear borrower fields. Fields in BorrowerMandatoryField
1105
    are randomized, other personal data is cleared too.
1106
    Patrons with issues are skipped.
1107
1108
=cut
1109
1110
sub anonymize {
1111
    my ( $self ) = @_;
1112
    if( $self->_result->issues->count ) {
1113
        warn "Exiting anonymize: patron ".$self->borrowernumber." still has issues";
1114
        return;
1115
    }
1116
    my $mandatory = { map { (lc $_, 1); }
1117
        split /\s*\|\s*/, C4::Context->preference('BorrowerMandatoryField') };
1118
    $mandatory->{userid} = 1; # needed since sub store does not clear field
1119
    my @columns = $self->_result->result_source->columns;
1120
    @columns = grep { !/borrowernumber|branchcode|categorycode|^date|password|flags|updated_on|lastseen|lang|login_attempts|flgAnonymized/ } @columns;
1121
    push @columns, 'dateofbirth'; # add this date back in
1122
    foreach my $col (@columns) {
1123
        if( $mandatory->{lc $col} ) {
1124
            my $str = $self->_anonymize_column($col);
1125
            $self->$col($str);
1126
        } else {
1127
            $self->$col(undef);
1128
        }
1129
    }
1130
    $self->flgAnonymized(1)->store;
1131
}
1132
1133
sub _anonymize_column {
1134
    my ( $self, $col ) = @_;
1135
    my $type = $self->_result->result_source->column_info($col)->{data_type};
1136
    if( $type =~ /char|text/ ) {
1137
        return Koha::Token->new->generate({ pattern => '\w{10}' });
1138
    } elsif( $type =~ /integer|int$|float|dec|double/ ) {
1139
        return 0;
1140
    } elsif( $type =~ /date|time/ ) {
1141
        return dt_from_string;
1142
    }
1143
}
1144
1064
=head3 can_see_patron_infos
1145
=head3 can_see_patron_infos
1065
1146
1066
my $can_see = $patron->can_see_patron_infos( $patron );
1147
my $can_see = $patron->can_see_patron_infos( $patron );
(-)a/Koha/Patrons.pm (+125 lines)
Lines 237-242 sub delete { Link Here
237
    return $rv;
237
    return $rv;
238
}
238
}
239
239
240
=head3 search_unsubscribed
241
242
    Koha::Patrons->search_unsubscribed;
243
244
    Returns a set of Koha patron objects for patrons that recently
245
    unsubscribed and are not locked (candidates for locking).
246
    Depends on UnsubscribeReflectionDelay.
247
248
=cut
249
250
sub search_unsubscribed {
251
    my ( $class ) = @_;
252
253
    my $delay = C4::Context->preference('UnsubscribeReflectionDelay');
254
    if( !defined($delay) || $delay eq q{} ) {
255
        # return empty set
256
        return $class->search({ borrowernumber => undef });
257
    }
258
    my $parser = Koha::Database->new->schema->storage->datetime_parser;
259
    my $dt = dt_from_string()->subtract( days => $delay );
260
    my $str = $parser->format_datetime($dt);
261
    my $fails = C4::Context->preference('FailedLoginAttempts');
262
    my $cond = $fails ? { '<', $fails } : { '!=', Koha::Patron::ALT_FAILURES };
263
    return $class->search(
264
        {
265
            'patron_consents.refused_on' => { '<=' => $str },
266
            'login_attempts' => $cond,
267
        },
268
        { join => 'patron_consents' },
269
    );
270
}
271
272
=head3 search_anonymize_candidates
273
274
    Koha::Patrons->search_anonymize_candidates({ locked => 1 });
275
276
    Returns a set of Koha patron objects for patrons whose account is expired
277
    and locked (if parameter set). These are candidates for anonymizing.
278
    Depends on PatronAnonymizeDelay.
279
280
=cut
281
282
sub search_anonymize_candidates {
283
    my ( $class, $params ) = @_;
284
285
    my $delay = C4::Context->preference('PatronAnonymizeDelay');
286
    if( !defined($delay) || $delay eq q{} ) {
287
        # return empty set
288
        return $class->search({ borrowernumber => undef });
289
    }
290
    my $cond = {};
291
    my $parser = Koha::Database->new->schema->storage->datetime_parser;
292
    my $dt = dt_from_string()->subtract( days => $delay );
293
    my $str = $parser->format_datetime($dt);
294
    $cond->{dateexpiry} = { '<=' => $str };
295
    if( $params->{locked} ) {
296
        my $fails = C4::Context->preference('FailedLoginAttempts');
297
        if( $fails ) {
298
            $cond->{login_attempts} = { '>=', $fails };
299
        } else {
300
            $cond->{login_attempts} = { '=', Koha::Patron::ALT_FAILURES };
301
        }
302
    }
303
    return $class->search( $cond );
304
}
305
306
=head3 search_anonymized
307
308
    Koha::Patrons->search_anonymized;
309
310
    Returns a set of Koha patron objects for patron accounts that have been
311
    anonymized before and could be removed.
312
    Depends on PatronRemovalDelay.
313
314
=cut
315
316
sub search_anonymized {
317
    my ( $class ) = @_;
318
319
    my $delay = C4::Context->preference('PatronRemovalDelay');
320
    if( !defined($delay) || $delay eq q{} ) {
321
        # return empty set
322
        return $class->search({ borrowernumber => undef });
323
    }
324
    my $cond = {};
325
    my $parser = Koha::Database->new->schema->storage->datetime_parser;
326
    my $dt = dt_from_string()->subtract( days => $delay );
327
    my $str = $parser->format_datetime($dt);
328
    $cond->{dateexpiry} = { '<=' => $str };
329
    $cond->{flgAnonymized} = 1;
330
    return $class->search( $cond );
331
}
332
333
=head3 lock
334
335
    Koha::Patrons->search({ some filters })->lock({ expire => 1, remove => 1 })
336
337
    Lock the passed set of patron objects. Optionally expire and remove holds.
338
    Wrapper around Koha::Patron->lock.
339
340
=cut
341
342
sub lock {
343
    my ( $self, $params ) = @_;
344
    while( my $patron = $self->next ) {
345
        $patron->lock($params);
346
    }
347
}
348
349
=head3 anonymize
350
351
    Koha::Patrons->search({ some filters })->anonymize;
352
353
    Anonymize passed set of patron objects.
354
    Wrapper around Koha::Patron->anonymize.
355
356
=cut
357
358
sub anonymize {
359
    my ( $self ) = @_;
360
    while( my $patron = $self->next ) {
361
        $patron->anonymize;
362
    }
363
}
364
240
=head3 _type
365
=head3 _type
241
366
242
=cut
367
=cut
(-)a/t/db_dependent/Koha/Patrons.t (-2 / +161 lines)
Lines 19-25 Link Here
19
19
20
use Modern::Perl;
20
use Modern::Perl;
21
21
22
use Test::More tests => 33;
22
use Test::More tests => 38;
23
use Test::Warn;
23
use Test::Warn;
24
use Test::Exception;
24
use Test::Exception;
25
use Time::Fake;
25
use Time::Fake;
Lines 1438-1443 subtest 'Log cardnumber change' => sub { Link Here
1438
    is( scalar @logs, 2, 'With BorrowerLogs, Change in cardnumber should be logged, as well as general alert of patron mod.' );
1438
    is( scalar @logs, 2, 'With BorrowerLogs, Change in cardnumber should be logged, as well as general alert of patron mod.' );
1439
};
1439
};
1440
1440
1441
subtest 'search_unsubscribed' => sub {
1442
    plan tests => 4;
1443
1444
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 3 );
1445
    t::lib::Mocks::mock_preference( 'UnsubscribeReflectionDelay', '' );
1446
    is( Koha::Patrons->search_unsubscribed->count, 0, 'Empty delay should return empty set' );
1447
1448
    my $patron1 = $builder->build_object({ class => 'Koha::Patrons' });
1449
    my $patron2 = $builder->build_object({ class => 'Koha::Patrons' });
1450
1451
    t::lib::Mocks::mock_preference( 'UnsubscribeReflectionDelay', 0 );
1452
    Koha::Patron::Consents->delete; # for correct counts
1453
    Koha::Patron::Consent->new({ borrowernumber => $patron1->borrowernumber, type => 'GDPR_PROCESSING',  refused_on => dt_from_string })->store;
1454
    is( Koha::Patrons->search_unsubscribed->count, 1, 'Find patron1' );
1455
1456
    # Add another refusal but shift the period
1457
    t::lib::Mocks::mock_preference( 'UnsubscribeReflectionDelay', 2 );
1458
    Koha::Patron::Consent->new({ borrowernumber => $patron2->borrowernumber, type => 'GDPR_PROCESSING',  refused_on => dt_from_string->subtract(days=>2) })->store;
1459
    is( Koha::Patrons->search_unsubscribed->count, 1, 'Find patron2 only' );
1460
1461
    # Try another (special) attempts setting
1462
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 0 );
1463
    # Since lockout is now disabled, unsubscribed compares with ALT_FAILURES
1464
    # Patron2 still matches: refused earlier, not locked
1465
    is( Koha::Patrons->search_unsubscribed->count, 1, 'Lockout disabled' );
1466
};
1467
1468
subtest 'search_anonymize_candidates' => sub {
1469
    plan tests => 5;
1470
    my $patron1 = $builder->build_object({ class => 'Koha::Patrons' });
1471
    my $patron2 = $builder->build_object({ class => 'Koha::Patrons' });
1472
    $patron1->dateexpiry( dt_from_string->add(days => 1) )->store;
1473
    $patron2->dateexpiry( dt_from_string->add(days => 1) )->store;
1474
1475
    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', q{} );
1476
    is( Koha::Patrons->search_anonymize_candidates->count, 0, 'Empty set' );
1477
1478
    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', 0 );
1479
    my $cnt = Koha::Patrons->search_anonymize_candidates->count;
1480
    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
1481
    $patron2->dateexpiry( dt_from_string->subtract(days => 3) )->store;
1482
    is( Koha::Patrons->search_anonymize_candidates->count, $cnt+2, 'Delay 0' );
1483
1484
    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', 2 );
1485
    $patron1->dateexpiry( dt_from_string->add(days => 1) )->store;
1486
    $patron2->dateexpiry( dt_from_string->add(days => 1) )->store;
1487
    $cnt = Koha::Patrons->search_anonymize_candidates->count;
1488
    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
1489
    $patron2->dateexpiry( dt_from_string->subtract(days => 3) )->store;
1490
    is( Koha::Patrons->search_anonymize_candidates->count, $cnt+1, 'Delay 2' );
1491
1492
    t::lib::Mocks::mock_preference( 'PatronAnonymizeDelay', 4 );
1493
    $patron1->dateexpiry( dt_from_string->add(days => 1) )->store;
1494
    $patron2->dateexpiry( dt_from_string->add(days => 1) )->store;
1495
    $cnt = Koha::Patrons->search_anonymize_candidates->count;
1496
    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
1497
    $patron2->dateexpiry( dt_from_string->subtract(days => 3) )->store;
1498
    is( Koha::Patrons->search_anonymize_candidates->count, $cnt, 'Delay 4' );
1499
1500
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 3 );
1501
    $patron1->dateexpiry( dt_from_string->subtract(days => 5) )->store;
1502
    $patron1->login_attempts(0)->store;
1503
    $patron2->dateexpiry( dt_from_string->subtract(days => 5) )->store;
1504
    $patron2->login_attempts(0)->store;
1505
    $cnt = Koha::Patrons->search_anonymize_candidates({locked => 1})->count;
1506
    $patron1->login_attempts(3)->store;
1507
    is( Koha::Patrons->search_anonymize_candidates({locked => 1})->count,
1508
        $cnt+1, 'Locked flag' );
1509
};
1510
1511
subtest 'search_anonymized' => sub {
1512
    plan tests => 3;
1513
    my $patron1 = $builder->build_object( { class => 'Koha::Patrons' } );
1514
1515
    t::lib::Mocks::mock_preference( 'PatronRemovalDelay', q{} );
1516
    is( Koha::Patrons->search_anonymized->count, 0, 'Empty set' );
1517
1518
    t::lib::Mocks::mock_preference( 'PatronRemovalDelay', 1 );
1519
    $patron1->dateexpiry( dt_from_string );
1520
    $patron1->flgAnonymized(1)->store;
1521
    my $cnt = Koha::Patrons->search_anonymized->count;
1522
    is( Koha::Patrons->search_anonymized->count, $cnt, 'Number unchanged' );
1523
    $patron1->dateexpiry( dt_from_string->subtract(days => 1) )->store;
1524
    is( Koha::Patrons->search_anonymized->count, $cnt+1, 'Found patron1' );
1525
};
1526
1527
subtest 'lock' => sub {
1528
    plan tests => 10;
1529
1530
    my $patron1 = $builder->build_object( { class => 'Koha::Patrons' } );
1531
    my $patron2 = $builder->build_object( { class => 'Koha::Patrons' } );
1532
    my $hold = $builder->build_object({
1533
        class => 'Koha::Holds',
1534
        value => { borrowernumber => $patron1->borrowernumber },
1535
    });
1536
1537
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 3 );
1538
    my $pwd = $patron1->password;
1539
    my $expiry = dt_from_string->add(days => 1);
1540
    $patron1->dateexpiry( $expiry );
1541
    $patron1->lock;
1542
    is( $patron1->login_attempts, 3, 'Check login_attempts' );
1543
    is( $patron1->password, $pwd, 'Password not changed' );
1544
    is( $patron1->dateexpiry, $expiry, 'Not expired yet' );
1545
    is( $patron1->holds->count, 1, 'No holds removed' );
1546
1547
    $patron1->lock({ expire => 1, remove => 1});
1548
    isnt( $patron1->dateexpiry, $expiry, 'Expiry date adjusted' );
1549
    is( $patron1->holds->count, 0, 'Holds removed' );
1550
1551
    # Disable lockout feature
1552
    t::lib::Mocks::mock_preference( 'FailedLoginAttempts', q{} );
1553
    $patron1->login_attempts(0);
1554
    $patron1->dateexpiry( $expiry );
1555
    $patron1->store;
1556
    $patron1->lock;
1557
    is( $patron1->login_attempts, Koha::Patron::ALT_FAILURES, 'Check login_attempts' );
1558
    isnt( $patron1->password, $pwd, 'Password changed' );
1559
1560
    # Trivial wrapper test (Koha::Patrons->lock)
1561
    $patron1->login_attempts(0)->store;
1562
    Koha::Patrons->search({ borrowernumber => [ $patron1->borrowernumber, $patron2->borrowernumber ] })->lock;
1563
    $patron1->discard_changes; # refresh
1564
    $patron2->discard_changes;
1565
    is( $patron1->login_attempts, Koha::Patron::ALT_FAILURES, 'Check login_attempts patron 1' );
1566
    is( $patron2->login_attempts, Koha::Patron::ALT_FAILURES, 'Check login_attempts patron 2' );
1567
};
1568
1569
subtest 'anonymize' => sub {
1570
    plan tests => 9;
1571
1572
    my $patron1 = $builder->build_object( { class => 'Koha::Patrons' } );
1573
    my $patron2 = $builder->build_object( { class => 'Koha::Patrons' } );
1574
1575
    # First try patron with issues
1576
    my $issue = $builder->build_object({ class => 'Koha::Checkouts', value => { borrowernumber => $patron2->borrowernumber } });
1577
    warning_like { $patron2->anonymize } qr/still has issues/, 'Skip patron with issues';
1578
    $issue->delete;
1579
1580
    t::lib::Mocks::mock_preference( 'BorrowerMandatoryField', 'surname|email|cardnumber' );
1581
    my $surname = $patron1->surname; # expect change, no clear
1582
    my $branchcode = $patron1->branchcode; # expect skip
1583
    $patron1->anonymize;
1584
    is($patron1->flgAnonymized, 1, 'Check flag' );
1585
1586
    is( $patron1->dateofbirth, undef, 'Birth date cleared' );
1587
    is( $patron1->firstname, undef, 'First name cleared' );
1588
    isnt( $patron1->surname, $surname, 'Surname changed' );
1589
    ok( $patron1->surname =~ /^\w{10}$/, 'Mandatory surname randomized' );
1590
    is( $patron1->branchcode, $branchcode, 'Branch code skipped' );
1591
1592
    # Test wrapper in Koha::Patrons
1593
    $patron1->surname($surname)->store; # restore
1594
    my $rs = Koha::Patrons->search({ borrowernumber => [ $patron1->borrowernumber, $patron2->borrowernumber ] })->anonymize;
1595
    $patron1->discard_changes; # refresh
1596
    isnt( $patron1->surname, $surname, 'Surname patron1 changed again' );
1597
    $patron2->discard_changes; # refresh
1598
    is( $patron2->firstname, undef, 'First name patron2 cleared' );
1599
};
1600
1441
$schema->storage->txn_rollback;
1601
$schema->storage->txn_rollback;
1442
1602
1443
subtest 'Test Koha::Patrons::merge' => sub {
1603
subtest 'Test Koha::Patrons::merge' => sub {
1444
- 

Return to bug 21336