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

(-)a/Koha/REST/V1/Patrons/Export.pm (+225 lines)
Line 0 Link Here
1
package Koha::REST::V1::Patrons::Export;
2
3
# This file is part of Koha.
4
#
5
# Koha is free software; you can redistribute it and/or modify it
6
# under the terms of the GNU General Public License as published by
7
# the Free Software Foundation; either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# Koha is distributed in the hope that it will be useful, but
11
# WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU General Public License for more details.
14
#
15
# You should have received a copy of the GNU General Public License
16
# along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
20
use Mojo::Base 'Mojolicious::Controller';
21
22
use Koha::Patrons;
23
24
use Scalar::Util qw(blessed);
25
use Try::Tiny;
26
27
=head1 NAME
28
29
Koha::REST::V1::Patrons::Export
30
31
=head1 API
32
33
=head2 Methods
34
35
=head3 get
36
37
Controller method that gets patron's related data, permission driven
38
39
=cut
40
41
sub get {
42
43
    my $c = shift->openapi->valid_input or return;
44
45
    my $patron = Koha::Patrons->find( $c->validation->param('patron_id') );
46
    my $body   = $c->validation->param('body');
47
48
    return try {
49
50
        unless ($patron) {
51
            return $c->render( status => 404, openapi => {
52
                error => "Patron not found."
53
            } );
54
        }
55
56
        unless ( C4::Context->preference('AllowGDPRPatronExport') ) {
57
            return $c->render(
58
                status  => 403,
59
                openapi => { error => "Configuration prevents patron data export" }
60
            );
61
        }
62
63
        my $args = $c->validation->output;
64
        my ( $filtered_params, $reserved_params, $path_params ) = $c->extract_reserved_params($args);
65
66
        # If no pagination parameters are passed, default
67
        $reserved_params->{_per_page} //= C4::Context->preference('RESTdefaultPageSize');
68
        $reserved_params->{_page}     //= 1;
69
70
        my $export = $patron->export;
71
        my ( $export_formatted, $total, $base_total ) = _format_export(
72
            $export, $reserved_params->{_per_page}, $reserved_params->{_page}
73
        );
74
75
        $c->add_pagination_headers(
76
            {
77
                total      => $total,
78
                base_total => $base_total,
79
                params     => $args,
80
            }
81
        );
82
83
        return $c->render( status => 200, openapi => $export_formatted );
84
    }
85
    catch {
86
        $c->unhandled_exception($_);
87
    };
88
}
89
90
=head3 get_public
91
92
Controller method that gets patron's related data, for unprivileged users
93
94
=cut
95
96
sub get_public {
97
98
    my $c = shift->openapi->valid_input or return;
99
100
    my $body      = $c->validation->param('body');
101
    my $patron_id = $c->validation->param('patron_id');
102
103
    return try {
104
105
        unless ( C4::Context->preference('AllowGDPRPatronExport') ) {
106
            return $c->render(
107
                status  => 403,
108
                openapi => { error => "Configuration prevents patron data export" }
109
            );
110
        }
111
112
        my $patron = $c->stash('koha.user');
113
114
        unless ( $patron->borrowernumber == $patron_id ) {
115
            return $c->render(
116
                status  => 403,
117
                openapi => {
118
                    error => "Accessing other patron's data is forbidden"
119
                }
120
            );
121
        }
122
123
        my $args = $c->validation->output;
124
        my ( $filtered_params, $reserved_params, $path_params ) = $c->extract_reserved_params($args);
125
126
        # If no pagination parameters are passed, default
127
        $reserved_params->{_per_page} //= C4::Context->preference('RESTdefaultPageSize');
128
        $reserved_params->{_page}     //= 1;
129
130
        my $export = $patron->export;
131
        my ( $export_formatted, $total, $base_total ) = _format_export(
132
            $export, $reserved_params->{_per_page}, $reserved_params->{_page}
133
        );
134
135
        $c->add_pagination_headers(
136
            {
137
                total      => $total,
138
                base_total => $base_total,
139
                params     => $args,
140
            }
141
        );
142
143
        return $c->render( status => 200, openapi => $export_formatted );
144
    }
145
    catch {
146
        $c->unhandled_exception($_);
147
    };
148
}
149
150
sub _format_export {
151
    my ($export, $per_page, $page) = @_;
152
153
    my $export_formatted = [];
154
    my $total = 0;
155
    my $skip = $per_page * ( $page-1 );
156
    foreach my $source ( sort keys %$export ) {
157
        if ( $total >= $per_page ) {
158
            last;
159
        }
160
161
        if ( $source eq 'Borrower' ) {
162
            if ( $skip > 0 ) {
163
                $skip--;
164
                next;
165
            }
166
167
            push @$export_formatted, _format_item( {
168
                source => $source,
169
                data   => $export->{$source}->to_api,
170
            } );
171
            $total++;
172
            next;
173
        }
174
175
        while ( my $row = $export->{$source}->next ) {
176
            if ( $skip > 0 ) {
177
                $skip--;
178
                next;
179
            }
180
            if ( $total >= $per_page ) {
181
                last;
182
            }
183
            my $data;
184
            if ( $row->can('to_api') ) {
185
                $data = $row->to_api;
186
            } elsif ( $row->can('unblessed') ) {
187
                $data = $row->unblessed;
188
            } else {
189
                $data = { $row->get_columns };
190
            }
191
192
            push @$export_formatted, _format_item( {
193
                source => $source,
194
                data   => $data,
195
            } );
196
            $total++;
197
        }
198
    }
199
200
    my $base_total = 0;
201
    foreach my $source ( keys %$export ) {
202
        if ( $source eq 'Borrower' ) {
203
            $base_total++;
204
        } else {
205
            $base_total = $base_total + $export->{$source}->count;
206
        }
207
    }
208
209
    return ( $export_formatted, $total, $base_total );
210
}
211
212
=head3 _format
213
214
=cut
215
216
sub _format_item {
217
    my ($params) = @_;
218
219
    return {
220
        type => $params->{'source'},
221
        data => $params->{'data'},
222
    };
223
}
224
225
1;
(-)a/t/db_dependent/api/v1/patrons_export.t (-1 / +309 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
2
3
# This file is part of Koha.
4
#
5
# Koha is free software; you can redistribute it and/or modify it
6
# under the terms of the GNU General Public License as published by
7
# the Free Software Foundation; either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# Koha is distributed in the hope that it will be useful, but
11
# WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU General Public License for more details.
14
#
15
# You should have received a copy of the GNU General Public License
16
# along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
20
use Test::More tests => 2;
21
22
use Test::Mojo;
23
24
use t::lib::TestBuilder;
25
use t::lib::Mocks;
26
27
my $schema  = Koha::Database->new->schema;
28
my $builder = t::lib::TestBuilder->new;
29
30
t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
31
32
# this is recommended because generation of test data may take a long time and
33
# Mojolicious would otherwise die with "Premature connection close"
34
$ENV{MOJO_INACTIVITY_TIMEOUT} = 120;
35
36
my $t = Test::Mojo->new('Koha::REST::V1');
37
38
subtest 'get() tests' => sub {
39
40
    plan tests => 8;
41
42
    $schema->storage->txn_begin;
43
    unauthorized_access_tests('GET', -1, undef);
44
    $schema->storage->txn_rollback;
45
46
    $schema->storage->txn_begin;
47
48
    my $generated = generate_test_data();
49
    my $patron = $generated->{patron};
50
    my $test_objects = $generated->{test_objects};
51
52
    my $password = '12345';
53
    my $librarian = $builder->build_object({
54
            class => 'Koha::Patrons',
55
            value => { flags => 2**4 }    # borrowers flag = 4
56
    });
57
    $librarian->set_password( { password => $password, skip_validation => 1 } );
58
    my $userid = $librarian->userid;
59
60
    t::lib::Mocks::mock_preference( 'AllowGDPRPatronExport', 1 );
61
    t::lib::Mocks::mock_preference( 'RESTdefaultPageSize', 20 );
62
63
    $t->get_ok("//$userid:$password@/api/v1/patrons/" . $patron->id
64
                         . '/export')
65
      ->status_is(200)
66
      ->json_is($test_objects);
67
68
    subtest 'test pagination' => sub {
69
70
        plan tests => 15;
71
72
        t::lib::Mocks::mock_preference( 'RESTdefaultPageSize', 1 );
73
        $t->get_ok("//$userid:$password@/api/v1/patrons/" . $patron->id
74
                         . '/export')
75
        ->status_is(200)
76
        ->json_is([$test_objects->[0]]);
77
        $t->get_ok("//$userid:$password@/api/v1/patrons/" . $patron->id
78
                         . '/export?_page=2')
79
        ->status_is(200)
80
        ->json_is([$test_objects->[1]]);
81
        $t->get_ok("//$userid:$password@/api/v1/patrons/" . $patron->id
82
                         . '/export?_page=3')
83
        ->status_is(200)
84
        ->json_is([$test_objects->[2]]);
85
        $t->get_ok("//$userid:$password@/api/v1/patrons/" . $patron->id
86
                         . '/export?_per_page=2&_page=1')
87
        ->status_is(200)
88
        ->json_is([$test_objects->[0],$test_objects->[1]]);
89
        $t->get_ok("//$userid:$password@/api/v1/patrons/" . $patron->id
90
                         . '/export?_per_page=2&_page=2')
91
        ->status_is(200)
92
        ->json_is([$test_objects->[2],$test_objects->[3]]);
93
        
94
    };
95
96
    t::lib::Mocks::mock_preference( 'AllowGDPRPatronExport', 0 );
97
    $t->get_ok("//$userid:$password@/api/v1/patrons/"
98
                         . $patron->id . '/export')
99
      ->status_is(403)
100
      ->json_is('/error', 'Configuration prevents patron data export');
101
102
    $schema->storage->txn_rollback;
103
104
};
105
106
subtest 'get_public() tests' => sub {
107
108
    plan tests => 11;
109
110
    $schema->storage->txn_begin;
111
    unauthorized_access_tests('GET', -1, undef, 1);
112
    $schema->storage->txn_rollback;
113
114
    $schema->storage->txn_begin;
115
116
    my $generated = generate_test_data();
117
    my $patron = $generated->{patron};
118
    my $test_objects = $generated->{test_objects};
119
    my $userid = $patron->userid;
120
    my $password = $patron->{cleartext_password};
121
122
    t::lib::Mocks::mock_preference( 'AllowGDPRPatronExport', 1 );
123
    t::lib::Mocks::mock_preference( 'RESTdefaultPageSize', 20 );
124
125
    $t->get_ok("//$userid:$password@/api/v1/public/patrons/"
126
                         . ($patron->id-1) . '/export')
127
      ->status_is(403)
128
      ->json_is('/error', "Accessing other patron's data is forbidden");
129
130
    $t->get_ok("//$userid:$password@/api/v1/public/patrons/"
131
                         . $patron->id . '/export')
132
      ->status_is(200)
133
      ->json_is($test_objects);
134
135
    subtest 'test pagination' => sub {
136
137
        plan tests => 15;
138
139
        t::lib::Mocks::mock_preference( 'RESTdefaultPageSize', 1 );
140
        $t->get_ok("//$userid:$password@/api/v1/public/patrons/" . $patron->id
141
                         . '/export')
142
        ->status_is(200)
143
        ->json_is([$test_objects->[0]]);
144
        $t->get_ok("//$userid:$password@/api/v1/public/patrons/" . $patron->id
145
                         . '/export?_page=2')
146
        ->status_is(200)
147
        ->json_is([$test_objects->[1]]);
148
        $t->get_ok("//$userid:$password@/api/v1/public/patrons/" . $patron->id
149
                         . '/export?_page=3')
150
        ->status_is(200)
151
        ->json_is([$test_objects->[2]]);
152
        $t->get_ok("//$userid:$password@/api/v1/public/patrons/" . $patron->id
153
                         . '/export?_per_page=2&_page=1')
154
        ->status_is(200)
155
        ->json_is([$test_objects->[0],$test_objects->[1]]);
156
        $t->get_ok("//$userid:$password@/api/v1/public/patrons/" . $patron->id
157
                         . '/export?_per_page=2&_page=2')
158
        ->status_is(200)
159
        ->json_is([$test_objects->[2],$test_objects->[3]]);
160
161
    };
162
163
    t::lib::Mocks::mock_preference( 'AllowGDPRPatronExport', 0 );
164
    $t->get_ok("//$userid:$password@/api/v1/public/patrons/"
165
                         . $patron->id . '/export')
166
      ->status_is(403)
167
      ->json_is('/error', 'Configuration prevents patron data export');
168
169
    $schema->storage->txn_rollback;
170
171
};
172
173
sub generate_test_data {
174
    my @related_sources = _get_related_sources();
175
    my $patron = $builder->build_object(
176
        {
177
            class => 'Koha::Patrons',
178
        }
179
    );
180
181
    my $password = '12345';
182
    $patron->set_password( { password => $password, skip_validation => 1 } );
183
    $patron->discard_changes;
184
    $patron->{cleartext_password} = $password;
185
    my $test_objects = {
186
        'Borrower' => $patron->to_api,
187
    };
188
189
    my $limit_data     = 5; # build this many random test objects
190
    my $generated_data = 0;
191
192
    my $result_source = Koha::Patron->new->_result()->result_source;
193
    foreach my $rel (   Koha::Patron->new->_result()->relationships() ) {
194
        if ($generated_data >= $limit_data) {
195
            last;
196
        }
197
198
        my $related_source = $result_source->related_source( $rel );
199
        my $source_name = $related_source->source_name;
200
201
        my $info = $result_source->relationship_info( $rel );
202
203
        # We are not interested in the "belongs_to" relationship of borrowers.
204
        # These are tables like branches, categories and sms_provider.
205
        if ( $info->{'attrs'}->{'is_depends_on'} ) {
206
            next;
207
        }
208
209
        ( my $rel_col = (keys %{$info->{'cond'}})[0] ) =~ s/^foreign\.//;
210
211
        # Generate test data into related tables
212
        my $built;
213
        if ( $related_source->result_class->can('koha_objects_class') ) {
214
            $built = $builder->build_object(
215
                {
216
                    class => $related_source->result_class->koha_objects_class,
217
                    value => { $rel_col => $patron->borrowernumber }
218
                }
219
            );
220
            if ( $built->can('to_api') ) {
221
                $built = $built->to_api;
222
            } elsif ( $built->can('unblessed') ) {
223
                $built = $built->unblessed;
224
            }
225
        } else {
226
            $built = $builder->build(
227
                {
228
                    source => $source_name,
229
                    value  => { $rel_col => $patron->borrowernumber }
230
                }
231
            );
232
        }
233
234
        $test_objects->{$source_name} = [] unless $test_objects->{$source_name};
235
        push @{ $test_objects->{$related_source->source_name} }, $built;
236
        $generated_data++;
237
    }
238
239
    my $test_objects_formatted = [];
240
    foreach my $source ( sort keys %$test_objects ) {
241
        if ( $source eq 'Borrower' ) {
242
            push @$test_objects_formatted, {
243
                data => $test_objects->{$source},
244
                type => $source,
245
            };
246
            next;
247
        }
248
249
        foreach my $item ( @{ $test_objects->{$source} } ) {
250
            push @$test_objects_formatted, {
251
                data => $item,
252
                type => $source,
253
            }
254
        }
255
    }
256
257
    return {
258
        patron => $patron,
259
        test_objects => $test_objects_formatted
260
    };
261
}
262
263
# Centralized tests for 401s and 403s assuming the endpoint requires
264
# borrowers flag for access
265
sub unauthorized_access_tests {
266
    my ($verb, $patron_id, $json, $public) = @_;
267
268
    my $endpoint = '/api/v1/' . ( $public ? 'public/' : '' ) . 'patrons';
269
    $endpoint .= ($patron_id) ? "/$patron_id/export" : '';
270
271
    subtest 'unauthorized access tests' => sub {
272
        plan tests => 5;
273
274
        my $verb_ok = lc($verb) . '_ok';
275
276
        $t->$verb_ok($endpoint => json => $json)
277
          ->status_is(401);
278
279
        my $unauthorized_patron = $builder->build_object(
280
            {
281
                class => 'Koha::Patrons',
282
                value => { flags => 0 }
283
            }
284
        );
285
        my $password = "12345";
286
        $unauthorized_patron->set_password(
287
            { password => $password, skip_validation => 1 } );
288
        my $unauth_userid = $unauthorized_patron->userid;
289
290
        $t->$verb_ok( "//$unauth_userid:$password\@$endpoint" => json => $json )
291
          ->status_is(403)
292
          ->json_has('/required_permissions');
293
    };
294
}
295
296
sub _get_related_sources {
297
    my $sources = {};
298
    my $res_source = Koha::Patron->new->_result()->result_source;
299
    foreach my $rel ( Koha::Patron->new->_result()->relationships() ) {
300
        my $related_source = $res_source->related_source($rel);
301
        my $info = $res_source->relationship_info( $rel );
302
        next if $info->{'attrs'}->{'is_depends_on'};
303
        next if $sources->{$related_source->source_name};
304
        $sources->{$related_source->source_name} = 1;
305
    }
306
    $sources->{'Borrower'} = 1; # add Borrower itself
307
    my @sorted = sort keys %$sources;
308
    return @sorted;
309
}

Return to bug 20028