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

(-)a/Koha/Patron.pm (+24 lines)
Lines 44-49 use Koha::CurbsidePickups; Link Here
44
use Koha::Old::Checkouts;
44
use Koha::Old::Checkouts;
45
use Koha::Patron::Attributes;
45
use Koha::Patron::Attributes;
46
use Koha::Patron::Categories;
46
use Koha::Patron::Categories;
47
use Koha::Patron::Consents;
47
use Koha::Patron::Debarments;
48
use Koha::Patron::Debarments;
48
use Koha::Patron::HouseboundProfile;
49
use Koha::Patron::HouseboundProfile;
49
use Koha::Patron::HouseboundRole;
50
use Koha::Patron::HouseboundRole;
Lines 2490-2495 sub get_savings { Link Here
2490
    )->next->get_column('total_savings') // 0;
2491
    )->next->get_column('total_savings') // 0;
2491
}
2492
}
2492
2493
2494
=head3 consent
2495
2496
    my $consent = $patron->consent(TYPE);
2497
2498
    Returns the first consent of type TYPE (there should be only one) or a new instance
2499
    of Koha::Patron::Consent.
2500
2501
=cut
2502
2503
sub consent {
2504
    my ( $self, $type ) = @_;
2505
    Koha::Exceptions::MissingParameter->throw('Missing consent type') if !$type;
2506
    my $consents = Koha::Patron::Consents->search(
2507
        {
2508
            borrowernumber => $self->borrowernumber,
2509
            type           => $type,
2510
        }
2511
    );
2512
    return $consents && $consents->count
2513
        ? $consents->next
2514
        : Koha::Patron::Consent->new( { borrowernumber => $self->borrowernumber, type => $type } );
2515
}
2516
2493
=head2 Internal methods
2517
=head2 Internal methods
2494
2518
2495
=head3 _type
2519
=head3 _type
(-)a/Koha/Patron/Consents.pm (+27 lines)
Lines 20-26 package Koha::Patron::Consents; Link Here
20
use Modern::Perl;
20
use Modern::Perl;
21
21
22
use base qw(Koha::Objects);
22
use base qw(Koha::Objects);
23
24
use C4::Context;
23
use Koha::Patron::Consent;
25
use Koha::Patron::Consent;
26
use Koha::Plugins;
24
27
25
=head1 NAME
28
=head1 NAME
26
29
Lines 34-41 Koha::Objects class for handling patron consents Link Here
34
37
35
=head2 Class Methods
38
=head2 Class Methods
36
39
40
=head3 available_types
41
42
    Returns an HASHref of available consent types like:
43
        { type1 => {}, type2 => {}, .. }
44
45
    Checks preferences OPACCustomConsentTypes and PrivacyPolicyConsent.
46
    Calls patron_consent_type plugins (if pref enabled).
47
48
    Note: The plugins return an ARRAYref with type, title and description like:
49
        [ my_type => { title => { lang => 1, .. }, description => { lang => 2, .. } } ]
50
37
=cut
51
=cut
38
52
53
sub available_types {
54
    my ($self) = shift;
55
    my $response = {};
56
    $response->{GDPR_PROCESSING} = 1 if C4::Context->preference('PrivacyPolicyConsent');
57
    if ( C4::Context->preference('OPACCustomConsentTypes') ) {
58
        foreach my $return ( Koha::Plugins->call('patron_consent_type') ) {
59
            next if ref($return) ne 'ARRAY' or @$return != 2;    # ignoring bad input
60
            $response->{ $return->[0] } = $return->[1];
61
        }
62
    }
63
    return $response;
64
}
65
39
=head3 _type
66
=head3 _type
40
67
41
=cut
68
=cut
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-patron-consent.tt (-44 / +50 lines)
Lines 36-76 Link Here
36
            </div>
36
            </div>
37
            <div class="col-lg-10 order-first order-md-first order-lg-2">
37
            <div class="col-lg-10 order-first order-md-first order-lg-2">
38
                <div id="patronconsents" class="maincontent">
38
                <div id="patronconsents" class="maincontent">
39
40
                    [% IF Koha.Preference('PrivacyPolicyConsent') %]
41
                    <div class="alert alert-warning">
42
                            <p>In order to keep you logged in, we need your consent to process personal data as specified in the privacy policy linked below.</p>
43
                            <p>Please save your consent below or log out. Thank you!</p>
44
                    </div>
45
                    [% END %]
46
47
                    <h1>Your consents</h1>
48
49
                    <form action="/cgi-bin/koha/opac-patron-consent.pl" method="post">
39
                    <form action="/cgi-bin/koha/opac-patron-consent.pl" method="post">
50
                        [% IF Koha.Preference('PrivacyPolicyConsent') %]
40
                        <input type="hidden" name="op" value="save"/>
51
                        <legend><h2 id="GDPR_consents">Privacy policy consents</h2></legend>
41
                        <input type="hidden" name="borrowernumber" value="[% patron.borrowernumber | html %]"/>
52
                            <input type="hidden" name="op" value="gdpr_proc_save"/>
42
                        <h1>Your consents</h1>
53
                            <input type="hidden" name="borrowernumber" value="[% patron.borrowernumber | html %]"/>
43
                        <br>
44
                        [% FOREACH consent IN consents %]
45
                            [% SET consent_type = consent.type %]
46
                            [% IF consent_type == 'GDPR_PROCESSING' %]
47
                                <legend><h2>Privacy policy consent</h2></legend>
48
                                <p>Please read the <a target="_blank" href="[% Koha.Preference('PrivacyPolicyURL') | url %]">privacy policy</a>.</p>
49
                                <p>In order to keep you logged in, we need your consent to process personal data as specified in the EU General Data Protection Regulation of May 25, 2018. If you would not agree, we will need to <strong>remove</strong> your account within a reasonable time.</p>
50
                                <p>Do you agree with our processing of your personal data as outlined in the policy?</p>
51
                            [% ELSIF consent_types.$consent_type %]
52
                                [% SET consent_title = ( consent_types.$consent_type.title.$lang || consent_types.$consent_type.title.en ) %]
53
                                [% SET consent_desc  = ( consent_types.$consent_type.description.$lang || consent_types.$consent_type.description.en )  %]
54
                                <legend><h2>[% consent_title | html %]</h2></legend>
55
                                <p>[% consent_desc | html %]</p>
56
                                <p>Do you agree?</p>
57
                            [% ELSE %]
58
                                <legend><h2>Consent for [% consent_type | html %]</h2></legend>
59
                                <p>Do you agree?</p>
60
                            [% END %]
54
                            <fieldset>
61
                            <fieldset>
55
                                <ul><li>
62
                                <input type="radio" name="check_[% consent_type | html %]" value="1"> Yes<br>
56
                                    <p>I have read the <a target="_blank" href="[% Koha.Preference('PrivacyPolicyURL') | url %]">privacy policy</a> and agree with your processing of my personal data as outlined therein.</p>
63
                                <input type="radio" name="check_[% consent_type | html %]" value="0"> No
57
                                    <p><input type="radio" name="gdpr_processing" value="agreed"> Yes, I agree.<br>
58
                                    <input type="radio" name="gdpr_processing" value="disagreed"> No, I do not agree. Please remove my account within a reasonable time.</p>
59
                                    [% IF gdpr_proc_consent %]
60
                                        <p>Your consent was registered on [% gdpr_proc_consent | $KohaDates with_hours => 1 %].</p>
61
                                    [% ELSIF gdpr_proc_refusal %]
62
                                        <p>You indicated recently that you do not consent, and we will process your request soon.</p>
63
                                    [% END %]
64
                                </li></ul>
65
                            </fieldset>
66
                            <fieldset class="action">
67
                                <input id="saveconsent" type="submit" value="Save" class="btn btn-primary" />
68
                            </fieldset>
64
                            </fieldset>
65
                            [% IF consent.given_on %]
66
                                <p class="consent_info">Your consent was registered on [% consent.given_on | html %].</p>
67
                            [% ELSIF consent.refused_on %]
68
                                <p class="dissent_info">We registered that you did not consent on [% consent.refused_on | html %].</p>
69
                            [% END %]
70
                            <br>
69
                        [% END %]
71
                        [% END %]
70
72
                        <fieldset class="action">
73
                            <input id="saveconsent" type="submit" value="Save" class="btn btn-primary" />
74
                        </fieldset>
71
                    </form>
75
                    </form>
72
76
73
                </div> <!-- / #userpasswd -->
77
                </div> <!-- / #patronconsents -->
74
            </div> <!-- / .col-lg-10 -->
78
            </div> <!-- / .col-lg-10 -->
75
        </div> <!-- / .row -->
79
        </div> <!-- / .row -->
76
    </div> <!-- / .container-fluid -->
80
    </div> <!-- / .container-fluid -->
Lines 79-105 Link Here
79
[% INCLUDE 'opac-bottom.inc' %]
83
[% INCLUDE 'opac-bottom.inc' %]
80
[% BLOCK jsinclude %]
84
[% BLOCK jsinclude %]
81
    <script>
85
    <script>
82
        var consent = null;
83
        $(document).ready(function() {
86
        $(document).ready(function() {
84
            [% IF gdpr_proc_consent %]
87
            [% FOREACH consent IN consents %]
85
                consent=1;
88
                [% IF consent.given_on %]
86
                $("input[type='radio'][value='agreed']").prop('checked',true);
89
                    $("input[type='radio'][name='check_[% consent.type | html %]'][value='1']").prop('checked',true);
87
                $(".alert").hide();
90
                [% ELSIF consent.refused_on %]
88
            [% ELSIF gdpr_proc_refusal %]
91
                    $("input[type='radio'][name='check_[% consent.type | html %]'][value='0']").prop('checked',true);
89
                consent=0;
92
                 [% END %]
90
                $("input[type='radio'][value='disagreed']").prop('checked',true);
91
            [% ELSE %]
92
            [% END %]
93
            [% END %]
94
            // Initially no choice is made or no change, so disable button
93
            $("#saveconsent").prop('disabled', true);
95
            $("#saveconsent").prop('disabled', true);
94
96
95
            $("input[type='radio']").click(function() {
97
            $("input[type='radio']").click(function() {
96
                var radio = $(this).val();
98
                $("#saveconsent").prop('disabled', false);
97
                if(radio=='agreed' && (consent==null || consent==0)) $("#saveconsent").prop('disabled', false);
99
                var v = $(this).val();
98
                if(radio=='disagreed' && (consent==null || consent==1)) $("#saveconsent").prop('disabled', false);
100
                // If former registration info present, toggle
99
                if(radio=='agreed') $(".alert").hide();
101
                if( v == 1 ) {
100
                if(radio=='disagreed') $(".alert").show();
102
                    $(this).parent().siblings('.consent_info').show();
103
                    $(this).parent().siblings('.dissent_info').hide();
104
                } else {
105
                    $(this).parent().siblings('.consent_info').hide();
106
                    $(this).parent().siblings('.dissent_info').show();
107
                }
101
            });
108
            });
102
103
        });
109
        });
104
    </script>
110
    </script>
105
[% END %]
111
[% END %]
(-)a/opac/opac-patron-consent.pl (-33 / +24 lines)
Lines 23-36 use CGI qw/-utf8/; Link Here
23
use C4::Auth qw( get_template_and_user );
23
use C4::Auth qw( get_template_and_user );
24
use C4::Output qw( output_html_with_http_headers );
24
use C4::Output qw( output_html_with_http_headers );
25
use Koha::DateUtils qw( dt_from_string );
25
use Koha::DateUtils qw( dt_from_string );
26
use Koha::Patron::Consents;
26
use Koha::Exceptions::Patron;
27
use Koha::Patrons;
27
use Koha::Patrons;
28
28
29
use constant GDPR_PROCESSING => 'GDPR_PROCESSING';
30
31
my $query = CGI->new;
29
my $query = CGI->new;
32
my $op = $query->param('op') // q{};
30
my $op = $query->param('op') // q{};
33
my $gdpr_check = $query->param('gdpr_processing') // q{};
31
my $vars = $query->Vars;
34
32
35
my ( $template, $borrowernumber, $cookie ) = get_template_and_user({
33
my ( $template, $borrowernumber, $cookie ) = get_template_and_user({
36
    template_name   => "opac-patron-consent.tt",
34
    template_name   => "opac-patron-consent.tt",
Lines 38-81 my ( $template, $borrowernumber, $cookie ) = get_template_and_user({ Link Here
38
    type            => "opac",
36
    type            => "opac",
39
});
37
});
40
38
41
my $patron = Koha::Patrons->find($borrowernumber);
39
my $patron = Koha::Patrons->find($borrowernumber)
42
my $gdpr_proc_consent;
40
    or Koha::Exceptions::Patron->throw("Patron id $borrowernumber not found");
43
if( C4::Context->preference('PrivacyPolicyConsent') ) {
41
44
    $gdpr_proc_consent = Koha::Patron::Consents->search({
42
# Get consent types and values
45
        borrowernumber => $borrowernumber,
43
my @consents;
46
        type => GDPR_PROCESSING,
44
my $consent_types = Koha::Patron::Consents->available_types;
47
    })->next;
45
foreach my $consent_type ( sort keys %$consent_types) {
48
    $gdpr_proc_consent //= Koha::Patron::Consent->new({
46
    push @consents, $patron->consent($consent_type);
49
        borrowernumber => $borrowernumber,
50
        type => GDPR_PROCESSING,
51
    });
52
}
47
}
53
48
54
# Handle saves here
49
# Handle saves here
55
if( $op eq 'gdpr_proc_save' && $gdpr_proc_consent ) {
50
my $needs_redirect;
56
    if( $gdpr_check eq 'agreed' ) {
51
foreach my $consent ( @consents ) {
57
        $gdpr_proc_consent->given_on( dt_from_string() );
52
    my $check = $vars->{ "check_".$consent->type };
58
        $gdpr_proc_consent->refused_on( undef );
53
    next if !defined($check);    # no choice made
59
    } elsif( $gdpr_check eq 'disagreed' ) {
54
    $needs_redirect = 1
60
        $gdpr_proc_consent->given_on( undef );
55
        if $consent->type eq q/GDPR_PROCESSING/ && !$check && C4::Context->preference('PrivacyPolicyConsent') eq 'Enforced';
61
        $gdpr_proc_consent->refused_on( dt_from_string() );
56
    next if $consent->given_on && $check || $consent->refused_on && !$check;
62
    }
57
        # No update if no consent change
63
    $gdpr_proc_consent->store;
58
    $consent->set({
59
        given_on => $check ? dt_from_string() : undef,
60
        refused_on => $check ? undef : dt_from_string(),
61
    })->store;
64
}
62
}
65
63
66
# If user refused GDPR consent and we enforce GDPR, logout (when saving)
64
# If user refused GDPR consent and we enforce GDPR, logout (when saving)
67
if( $op =~ /save/ && C4::Context->preference('PrivacyPolicyConsent') eq 'Enforced' && $gdpr_proc_consent->refused_on )
65
if( $needs_redirect ) {
68
{
69
    print $query->redirect('/cgi-bin/koha/opac-main.pl?logout.x=1');
66
    print $query->redirect('/cgi-bin/koha/opac-main.pl?logout.x=1');
70
    exit;
67
    exit;
71
}
68
}
72
69
73
$template->param( patron => $patron );
70
$template->param( patron => $patron, consents => \@consents, consent_types => $consent_types );
74
if( $gdpr_proc_consent ) {
75
    $template->param(
76
        gdpr_proc_consent => $gdpr_proc_consent->given_on // q{},
77
        gdpr_proc_refusal => $gdpr_proc_consent->refused_on // q{},
78
    );
79
}
80
71
81
output_html_with_http_headers $query, $cookie, $template->output, undef, { force_no_caching => 1 };
72
output_html_with_http_headers $query, $cookie, $template->output, undef, { force_no_caching => 1 };
(-)a/t/db_dependent/Koha/Patron.t (-1 / +21 lines)
Lines 19-25 Link Here
19
19
20
use Modern::Perl;
20
use Modern::Perl;
21
21
22
use Test::More tests => 24;
22
use Test::More tests => 25;
23
use Test::Exception;
23
use Test::Exception;
24
use Test::Warn;
24
use Test::Warn;
25
25
Lines 1471-1476 subtest 'update privacy tests' => sub { Link Here
1471
1471
1472
    plan tests => 5;
1472
    plan tests => 5;
1473
1473
1474
    $schema->storage->txn_begin;
1474
    my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { privacy => 1 } });
1475
    my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { privacy => 1 } });
1475
1476
1476
    my $old_checkout = $builder->build_object({ class => 'Koha::Old::Checkouts', value => { borrowernumber => $patron->id } });
1477
    my $old_checkout = $builder->build_object({ class => 'Koha::Old::Checkouts', value => { borrowernumber => $patron->id } });
Lines 1497-1500 subtest 'update privacy tests' => sub { Link Here
1497
1498
1498
    is( $old_checkout->borrowernumber, $anon_patron->id, "Checkout is successfully anonymized");
1499
    is( $old_checkout->borrowernumber, $anon_patron->id, "Checkout is successfully anonymized");
1499
    is( $patron->privacy(), 2, "Patron privacy is successfully updated");
1500
    is( $patron->privacy(), 2, "Patron privacy is successfully updated");
1501
    $schema->storage->txn_rollback;
1502
};
1503
1504
subtest 'test patron_consent' => sub {
1505
    plan tests => 4;
1506
    $schema->storage->txn_begin;
1507
1508
    my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
1509
    throws_ok { $patron->consent } 'Koha::Exceptions::MissingParameter', 'missing type';
1510
1511
    my $consent = $patron->consent('GDPR_PROCESSING');
1512
    is( ref $consent, 'Koha::Patron::Consent', 'return type check' );
1513
    $consent->given_on('2021-02-03')->store;
1514
    undef $consent;
1515
    is( $patron->consent('GDPR_PROCESSING')->given_on, '2021-02-03 00:00:00', 'check date' );
1516
1517
    is( $patron->consent('NOT_EXIST')->refused_on, undef, 'New empty object for new type' );
1518
1519
    $schema->storage->txn_rollback;
1500
};
1520
};
(-)a/t/db_dependent/Koha/Patron/Consents.t (-3 / +43 lines)
Lines 18-26 Link Here
18
# along with Koha; if not, see <http://www.gnu.org/licenses>.
18
# along with Koha; if not, see <http://www.gnu.org/licenses>.
19
19
20
use Modern::Perl;
20
use Modern::Perl;
21
use Data::Dumper qw/Dumper/;
22
use Test::More tests => 2;
23
use Test::MockModule;
24
use Test::MockObject;
21
25
22
use Test::More tests => 1;
26
use t::lib::Mocks;
23
24
use t::lib::TestBuilder;
27
use t::lib::TestBuilder;
25
28
26
use Koha::Database;
29
use Koha::Database;
Lines 46-48 subtest 'Basic tests for Koha::Patron::Consent' => sub { Link Here
46
49
47
    $schema->storage->txn_rollback;
50
    $schema->storage->txn_rollback;
48
};
51
};
49
- 
52
53
subtest 'Method available_types' => sub {
54
    plan tests => 7;
55
    $schema->storage->txn_begin;
56
57
    # Mock get_enabled_plugins
58
    my $plugins        = [];
59
    my $plugins_module = Test::MockModule->new('Koha::Plugins');
60
    $plugins_module->mock( 'get_enabled_plugins', sub { return @$plugins } );
61
    my $plugin_1 = Test::MockObject->new;
62
    my $data_1   = [ 'plugin_1', { title => { en => 'Title1' }, description => { en => 'Desc1' } } ];
63
    $plugin_1->mock( 'patron_consent_type', sub { return $data_1; } );
64
    my $plugin_2 = Test::MockObject->new;
65
    my $data_2   = [ 'plugin_2', { title => { en => 'Title2' }, description => { en => 'Desc2' } } ];
66
    $plugin_2->mock( 'patron_consent_type', sub { return $data_2; } );
67
    $plugins = [ $plugin_1, $plugin_2 ];
68
69
    t::lib::Mocks::mock_preference( 'PrivacyPolicyConsent',   'Enforced' );
70
    t::lib::Mocks::mock_preference( 'OPACCustomConsentTypes', 0 );
71
    my $types = Koha::Patron::Consents->available_types;
72
    is( keys %$types, 1, 'Expect one plugin for privacy policy' );
73
    t::lib::Mocks::mock_preference( 'OPACCustomConsentTypes', 1 );
74
    $types = Koha::Patron::Consents->available_types;
75
    is( keys %$types, 3, 'Expect three plugins when allowing custom consents' );
76
    t::lib::Mocks::mock_preference( 'PrivacyPolicyConsent', '' );
77
    $types = Koha::Patron::Consents->available_types;
78
    is( keys %$types,              2,     'Expect two plugins, when pref disabled' );
79
    is( $types->{GDPR_PROCESSING}, undef, 'GDPR key should not be found' );
80
    is_deeply( $types->{plugin_2}, $data_2->[1], 'Check type hash' );
81
82
    # Let plugin_2 return bad data (hashref)
83
    $data_2 = { not_expected => 1 };
84
    $types  = Koha::Patron::Consents->available_types;
85
    is( keys %$types, 1, 'Expect one plugin, when plugin_2 fails' );
86
    is_deeply( $types->{plugin_1}, $data_1->[1], 'Check type hash' );
87
88
    $schema->storage->txn_rollback;
89
};

Return to bug 31503