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

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

Return to bug 21336