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

(-)a/Koha/Patron.pm (+24 lines)
Lines 47-52 use Koha::Old::Checkouts; Link Here
47
use Koha::OverdueRules;
47
use Koha::OverdueRules;
48
use Koha::Patron::Attributes;
48
use Koha::Patron::Attributes;
49
use Koha::Patron::Categories;
49
use Koha::Patron::Categories;
50
use Koha::Patron::Consents;
50
use Koha::Patron::Debarments;
51
use Koha::Patron::Debarments;
51
use Koha::Patron::HouseboundProfile;
52
use Koha::Patron::HouseboundProfile;
52
use Koha::Patron::HouseboundRole;
53
use Koha::Patron::HouseboundRole;
Lines 2704-2709 sub alert_subscriptions { Link Here
2704
    return Koha::Subscriptions->search( { subscriptionid => \@subscription_ids } );
2705
    return Koha::Subscriptions->search( { subscriptionid => \@subscription_ids } );
2705
}
2706
}
2706
2707
2708
=head3 consent
2709
2710
    my $consent = $patron->consent(TYPE);
2711
2712
    Returns the first consent of type TYPE (there should be only one) or a new instance
2713
    of Koha::Patron::Consent.
2714
2715
=cut
2716
2717
sub consent {
2718
    my ( $self, $type ) = @_;
2719
    Koha::Exceptions::MissingParameter->throw('Missing consent type') if !$type;
2720
    my $consents = Koha::Patron::Consents->search(
2721
        {
2722
            borrowernumber => $self->borrowernumber,
2723
            type           => $type,
2724
        }
2725
    );
2726
    return $consents && $consents->count
2727
        ? $consents->next
2728
        : Koha::Patron::Consent->new( { borrowernumber => $self->borrowernumber, type => $type } );
2729
}
2730
2707
=head2 Internal methods
2731
=head2 Internal methods
2708
2732
2709
=head3 _type
2733
=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 32-69 Link Here
32
            </div>
32
            </div>
33
            <div class="col-lg-10 order-first order-md-first order-lg-2">
33
            <div class="col-lg-10 order-first order-md-first order-lg-2">
34
                <div id="patronconsents" class="maincontent">
34
                <div id="patronconsents" class="maincontent">
35
36
                    [% IF Koha.Preference('PrivacyPolicyConsent') %]
37
                    <div class="alert alert-warning">
38
                            <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>
39
                            <p>Please save your consent below or log out. Thank you!</p>
40
                    </div>
41
                    [% END %]
42
43
                    <h1>Your consents</h1>
44
45
                    <form action="/cgi-bin/koha/opac-patron-consent.pl" method="post">
35
                    <form action="/cgi-bin/koha/opac-patron-consent.pl" method="post">
46
                        [% IF Koha.Preference('PrivacyPolicyConsent') %]
36
                        <input type="hidden" name="op" value="save"/>
47
                        <legend><h2 id="GDPR_consents">Privacy policy consents</h2></legend>
37
                        <input type="hidden" name="borrowernumber" value="[% patron.borrowernumber | html %]"/>
48
                            <input type="hidden" name="op" value="gdpr_proc_save"/>
38
                        <h1>Your consents</h1>
49
                            <input type="hidden" name="borrowernumber" value="[% patron.borrowernumber | html %]"/>
39
                        <br>
40
                        [% FOREACH consent IN consents %]
41
                            [% SET consent_type = consent.type %]
42
                            [% IF consent_type == 'GDPR_PROCESSING' %]
43
                                <legend><h2>Privacy policy consent</h2></legend>
44
                                <p>Please read the <a target="_blank" href="[% Koha.Preference('PrivacyPolicyURL') | url %]">privacy policy</a>.</p>
45
                                <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>
46
                                <p>Do you agree with our processing of your personal data as outlined in the policy?</p>
47
                            [% ELSIF consent_types.$consent_type %]
48
                                [% SET consent_title = ( consent_types.$consent_type.title.$lang || consent_types.$consent_type.title.en ) %]
49
                                [% SET consent_desc  = ( consent_types.$consent_type.description.$lang || consent_types.$consent_type.description.en )  %]
50
                                <legend><h2>[% consent_title | html %]</h2></legend>
51
                                <p>[% consent_desc | html %]</p>
52
                                <p>Do you agree?</p>
53
                            [% ELSE %]
54
                                <legend><h2>Consent for [% consent_type | html %]</h2></legend>
55
                                <p>Do you agree?</p>
56
                            [% END %]
50
                            <fieldset>
57
                            <fieldset>
51
                                <ul><li>
58
                                <input type="radio" name="check_[% consent_type | html %]" value="1"> Yes<br>
52
                                    <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>
59
                                <input type="radio" name="check_[% consent_type | html %]" value="0"> No
53
                                    <p><input type="radio" name="gdpr_processing" value="agreed"> Yes, I agree.<br>
54
                                    <input type="radio" name="gdpr_processing" value="disagreed"> No, I do not agree. Please remove my account within a reasonable time.</p>
55
                                    [% IF gdpr_proc_consent %]
56
                                        <p>Your consent was registered on [% gdpr_proc_consent | $KohaDates with_hours => 1 %].</p>
57
                                    [% ELSIF gdpr_proc_refusal %]
58
                                        <p>You indicated recently that you do not consent, and we will process your request soon.</p>
59
                                    [% END %]
60
                                </li></ul>
61
                            </fieldset>
62
                            <fieldset class="action">
63
                                <input id="saveconsent" type="submit" value="Save" class="btn btn-primary" />
64
                            </fieldset>
60
                            </fieldset>
61
                            [% IF consent.given_on %]
62
                                <p class="consent_info">Your consent was registered on [% consent.given_on | html %].</p>
63
                            [% ELSIF consent.refused_on %]
64
                                <p class="dissent_info">We registered that you did not consent on [% consent.refused_on | html %].</p>
65
                            [% END %]
66
                            <br>
65
                        [% END %]
67
                        [% END %]
66
68
                        <fieldset class="action">
69
                            <input id="saveconsent" type="submit" value="Save" class="btn btn-primary" />
70
                        </fieldset>
67
                    </form>
71
                    </form>
68
72
69
                    [% IF Koha.Preference('CookieConsent') %]
73
                    [% IF Koha.Preference('CookieConsent') %]
Lines 71-77 Link Here
71
                        <button id="viewCookieConsents" type="button" class="btn btn-success">View your cookie consents</button>
75
                        <button id="viewCookieConsents" type="button" class="btn btn-success">View your cookie consents</button>
72
                    [% END %]
76
                    [% END %]
73
77
74
                </div> <!-- / #userpasswd -->
78
                </div> <!-- / #patronconsents -->
75
            </div> <!-- / .col-lg-10 -->
79
            </div> <!-- / .col-lg-10 -->
76
        </div> <!-- / .row -->
80
        </div> <!-- / .row -->
77
    </div> <!-- / .container-fluid -->
81
    </div> <!-- / .container-fluid -->
Lines 80-106 Link Here
80
[% INCLUDE 'opac-bottom.inc' %]
84
[% INCLUDE 'opac-bottom.inc' %]
81
[% BLOCK jsinclude %]
85
[% BLOCK jsinclude %]
82
    <script>
86
    <script>
83
        var consent = null;
84
        $(document).ready(function() {
87
        $(document).ready(function() {
85
            [% IF gdpr_proc_consent %]
88
            [% FOREACH consent IN consents %]
86
                consent=1;
89
                [% IF consent.given_on %]
87
                $("input[type='radio'][value='agreed']").prop('checked',true);
90
                    $("input[type='radio'][name='check_[% consent.type | html %]'][value='1']").prop('checked',true);
88
                $(".alert").hide();
91
                [% ELSIF consent.refused_on %]
89
            [% ELSIF gdpr_proc_refusal %]
92
                    $("input[type='radio'][name='check_[% consent.type | html %]'][value='0']").prop('checked',true);
90
                consent=0;
93
                 [% END %]
91
                $("input[type='radio'][value='disagreed']").prop('checked',true);
92
            [% ELSE %]
93
            [% END %]
94
            [% END %]
95
            // Initially no choice is made or no change, so disable button
94
            $("#saveconsent").prop('disabled', true);
96
            $("#saveconsent").prop('disabled', true);
95
97
96
            $("input[type='radio']").click(function() {
98
            $("input[type='radio']").click(function() {
97
                var radio = $(this).val();
99
                $("#saveconsent").prop('disabled', false);
98
                if(radio=='agreed' && (consent==null || consent==0)) $("#saveconsent").prop('disabled', false);
100
                var v = $(this).val();
99
                if(radio=='disagreed' && (consent==null || consent==1)) $("#saveconsent").prop('disabled', false);
101
                // If former registration info present, toggle
100
                if(radio=='agreed') $(".alert").hide();
102
                if( v == 1 ) {
101
                if(radio=='disagreed') $(".alert").show();
103
                    $(this).parent().siblings('.consent_info').show();
104
                    $(this).parent().siblings('.dissent_info').hide();
105
                } else {
106
                    $(this).parent().siblings('.consent_info').hide();
107
                    $(this).parent().siblings('.dissent_info').show();
108
                }
102
            });
109
            });
103
104
        });
110
        });
105
    </script>
111
    </script>
106
[% END %]
112
[% 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 (+21 lines)
Lines 1859-1864 subtest 'update privacy tests' => sub { Link Here
1859
1859
1860
    plan tests => 5;
1860
    plan tests => 5;
1861
1861
1862
    $schema->storage->txn_begin;
1862
    my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { privacy => 1 } });
1863
    my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { privacy => 1 } });
1863
1864
1864
    my $old_checkout = $builder->build_object({ class => 'Koha::Old::Checkouts', value => { borrowernumber => $patron->id } });
1865
    my $old_checkout = $builder->build_object({ class => 'Koha::Old::Checkouts', value => { borrowernumber => $patron->id } });
Lines 1892-1897 subtest 'update privacy tests' => sub { Link Here
1892
subtest 'alert_subscriptions tests' => sub {
1893
subtest 'alert_subscriptions tests' => sub {
1893
1894
1894
    plan tests => 3;
1895
    plan tests => 3;
1896
    $schema->storage->txn_begin;
1895
1897
1896
    my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
1898
    my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
1897
1899
Lines 1908-1913 subtest 'alert_subscriptions tests' => sub { Link Here
1908
    is( $subscriptions[1]->subscriptionid, $subscription2->subscriptionid, "Second subscribed alert is correct" );
1910
    is( $subscriptions[1]->subscriptionid, $subscription2->subscriptionid, "Second subscribed alert is correct" );
1909
1911
1910
    $patron->discard_changes;
1912
    $patron->discard_changes;
1913
    $schema->storage->txn_rollback;
1914
};
1915
1916
subtest 'test patron_consent' => sub {
1917
    plan tests => 4;
1918
    $schema->storage->txn_begin;
1919
1920
    my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
1921
    throws_ok { $patron->consent } 'Koha::Exceptions::MissingParameter', 'missing type';
1922
1923
    my $consent = $patron->consent('GDPR_PROCESSING');
1924
    is( ref $consent, 'Koha::Patron::Consent', 'return type check' );
1925
    $consent->given_on('2021-02-03')->store;
1926
    undef $consent;
1927
    is( $patron->consent('GDPR_PROCESSING')->given_on, '2021-02-03 00:00:00', 'check date' );
1928
1929
    is( $patron->consent('NOT_EXIST')->refused_on, undef, 'New empty object for new type' );
1930
1931
    $schema->storage->txn_rollback;
1911
};
1932
};
1912
1933
1913
subtest 'update_lastseen tests' => sub {
1934
subtest 'update_lastseen tests' => sub {
(-)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