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 2546-2551 sub get_savings { Link Here
2546
    )->next->get_column('total_savings') // 0;
2547
    )->next->get_column('total_savings') // 0;
2547
}
2548
}
2548
2549
2550
=head3 consent
2551
2552
    my $consent = $patron->consent(TYPE);
2553
2554
    Returns the first consent of type TYPE (there should be only one) or a new instance
2555
    of Koha::Patron::Consent.
2556
2557
=cut
2558
2559
sub consent {
2560
    my ( $self, $type ) = @_;
2561
    Koha::Exceptions::MissingParameter->throw('Missing consent type') if !$type;
2562
    my $consents = Koha::Patron::Consents->search(
2563
        {
2564
            borrowernumber => $self->borrowernumber,
2565
            type           => $type,
2566
        }
2567
    );
2568
    return $consents && $consents->count
2569
        ? $consents->next
2570
        : Koha::Patron::Consent->new( { borrowernumber => $self->borrowernumber, type => $type } );
2571
}
2572
2549
=head2 Internal methods
2573
=head2 Internal methods
2550
2574
2551
=head3 _type
2575
=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-73 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
                    [% IF Koha.Preference('CookieConsent') %]
77
                    [% IF Koha.Preference('CookieConsent') %]
Lines 75-81 Link Here
75
                        <button id="viewCookieConsents" type="button" class="btn btn-success">View your cookie consents</button>
79
                        <button id="viewCookieConsents" type="button" class="btn btn-success">View your cookie consents</button>
76
                    [% END %]
80
                    [% END %]
77
81
78
                </div> <!-- / #userpasswd -->
82
                </div> <!-- / #patronconsents -->
79
            </div> <!-- / .col-lg-10 -->
83
            </div> <!-- / .col-lg-10 -->
80
        </div> <!-- / .row -->
84
        </div> <!-- / .row -->
81
    </div> <!-- / .container-fluid -->
85
    </div> <!-- / .container-fluid -->
Lines 84-110 Link Here
84
[% INCLUDE 'opac-bottom.inc' %]
88
[% INCLUDE 'opac-bottom.inc' %]
85
[% BLOCK jsinclude %]
89
[% BLOCK jsinclude %]
86
    <script>
90
    <script>
87
        var consent = null;
88
        $(document).ready(function() {
91
        $(document).ready(function() {
89
            [% IF gdpr_proc_consent %]
92
            [% FOREACH consent IN consents %]
90
                consent=1;
93
                [% IF consent.given_on %]
91
                $("input[type='radio'][value='agreed']").prop('checked',true);
94
                    $("input[type='radio'][name='check_[% consent.type | html %]'][value='1']").prop('checked',true);
92
                $(".alert").hide();
95
                [% ELSIF consent.refused_on %]
93
            [% ELSIF gdpr_proc_refusal %]
96
                    $("input[type='radio'][name='check_[% consent.type | html %]'][value='0']").prop('checked',true);
94
                consent=0;
97
                 [% END %]
95
                $("input[type='radio'][value='disagreed']").prop('checked',true);
96
            [% ELSE %]
97
            [% END %]
98
            [% END %]
99
            // Initially no choice is made or no change, so disable button
98
            $("#saveconsent").prop('disabled', true);
100
            $("#saveconsent").prop('disabled', true);
99
101
100
            $("input[type='radio']").click(function() {
102
            $("input[type='radio']").click(function() {
101
                var radio = $(this).val();
103
                $("#saveconsent").prop('disabled', false);
102
                if(radio=='agreed' && (consent==null || consent==0)) $("#saveconsent").prop('disabled', false);
104
                var v = $(this).val();
103
                if(radio=='disagreed' && (consent==null || consent==1)) $("#saveconsent").prop('disabled', false);
105
                // If former registration info present, toggle
104
                if(radio=='agreed') $(".alert").hide();
106
                if( v == 1 ) {
105
                if(radio=='disagreed') $(".alert").show();
107
                    $(this).parent().siblings('.consent_info').show();
108
                    $(this).parent().siblings('.dissent_info').hide();
109
                } else {
110
                    $(this).parent().siblings('.consent_info').hide();
111
                    $(this).parent().siblings('.dissent_info').show();
112
                }
106
            });
113
            });
107
108
        });
114
        });
109
    </script>
115
    </script>
110
[% END %]
116
[% 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 (+20 lines)
Lines 1522-1527 subtest 'update privacy tests' => sub { Link Here
1522
1522
1523
    plan tests => 5;
1523
    plan tests => 5;
1524
1524
1525
    $schema->storage->txn_begin;
1525
    my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { privacy => 1 } });
1526
    my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { privacy => 1 } });
1526
1527
1527
    my $old_checkout = $builder->build_object({ class => 'Koha::Old::Checkouts', value => { borrowernumber => $patron->id } });
1528
    my $old_checkout = $builder->build_object({ class => 'Koha::Old::Checkouts', value => { borrowernumber => $patron->id } });
Lines 1548-1551 subtest 'update privacy tests' => sub { Link Here
1548
1549
1549
    is( $old_checkout->borrowernumber, $anon_patron->id, "Checkout is successfully anonymized");
1550
    is( $old_checkout->borrowernumber, $anon_patron->id, "Checkout is successfully anonymized");
1550
    is( $patron->privacy(), 2, "Patron privacy is successfully updated");
1551
    is( $patron->privacy(), 2, "Patron privacy is successfully updated");
1552
    $schema->storage->txn_rollback;
1553
};
1554
1555
subtest 'test patron_consent' => sub {
1556
    plan tests => 4;
1557
    $schema->storage->txn_begin;
1558
1559
    my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
1560
    throws_ok { $patron->consent } 'Koha::Exceptions::MissingParameter', 'missing type';
1561
1562
    my $consent = $patron->consent('GDPR_PROCESSING');
1563
    is( ref $consent, 'Koha::Patron::Consent', 'return type check' );
1564
    $consent->given_on('2021-02-03')->store;
1565
    undef $consent;
1566
    is( $patron->consent('GDPR_PROCESSING')->given_on, '2021-02-03 00:00:00', 'check date' );
1567
1568
    is( $patron->consent('NOT_EXIST')->refused_on, undef, 'New empty object for new type' );
1569
1570
    $schema->storage->txn_rollback;
1551
};
1571
};
(-)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