From f712a9db995196bebaf2904e605850d7c78d4c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Cohen=20Arazi?= Date: Wed, 14 Jan 2026 20:21:58 +0000 Subject: [PATCH] Bug 41622: Make patron export use Koha::CSV This patch introduces Koha::CSV::Patrons and updates misc/export_borrowers.pl to use it, providing consistent CSV generation with proper quoting and delimiter handling. Changes: - Add Koha::CSV::Patrons with dynamic field support - Update misc/export_borrowers.pl to use Koha::CSV::Patrons - Simplify export logic by removing manual CSV handling - Maintain all existing functionality (field selection, custom separator) The new class supports configurable field lists, making it flexible for different patron export scenarios while ensuring consistent CSV formatting. Test plan: 1. Apply patch 2. Run: $ ktd --shell k$ prove t/db_dependent/Koha/CSV/Patrons.t => SUCCESS: Tests pass! 3. Test patron export: k$ misc/export_borrowers.pl --show-header > patrons.csv => SUCCESS: CSV generated with all fields 4. Test with specific fields: k$ misc/export_borrowers.pl -H -f cardnumber -f surname -f firstname > patrons.csv => SUCCESS: Only specified fields exported 5. Test with custom separator: k$ misc/export_borrowers.pl -H -s=";" > patrons.csv => SUCCESS: Semicolon separator used 6. Open CSV in Excel => SUCCESS: Fields display correctly without quote issues 7. Sign off :-D --- Koha/CSV/Patrons.pm | 134 +++++++++++++++++++++++++++++ misc/export_borrowers.pl | 34 +++----- t/db_dependent/Koha/CSV/Patrons.t | 138 ++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 Koha/CSV/Patrons.pm create mode 100755 t/db_dependent/Koha/CSV/Patrons.t diff --git a/Koha/CSV/Patrons.pm b/Koha/CSV/Patrons.pm new file mode 100644 index 0000000000..cf56604d60 --- /dev/null +++ b/Koha/CSV/Patrons.pm @@ -0,0 +1,134 @@ +package Koha::CSV::Patrons; + +# Copyright 2026 Theke Solutions +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use base 'Koha::CSV'; + +=head1 NAME + +Koha::CSV::Patrons - CSV generator for patron exports + +=head1 SYNOPSIS + + my $csv = Koha::CSV::Patrons->new({ fields => \@fields }); + + # Print headers + $csv->print_headers($fh); + + # Print patron rows + foreach my $patron (@patrons) { + $csv->print_patron($fh, $patron); + } + +=head1 DESCRIPTION + +Specialized CSV generator for patron exports with configurable fields. + +=cut + +=head2 new + + my $csv = Koha::CSV::Patrons->new({ fields => \@fields }); + +Creates a new Koha::CSV::Patrons object. Accepts optional fields parameter +to specify which patron fields to export. + +=cut + +sub new { + my ( $class, $params ) = @_; + my $self = $class->SUPER::new($params); + + $self->{_fields} = $params->{fields} || []; + + return $self; +} + +=head2 fields + + my $fields = $csv->fields(); + $csv->fields(\@new_fields); + +Get or set the list of fields to export. + +=cut + +sub fields { + my ( $self, $fields ) = @_; + + if ($fields) { + $self->{_fields} = $fields; + } + + return $self->{_fields}; +} + +=head2 print_headers + + $csv->print_headers($fh); + +Prints the CSV headers (field names) to a filehandle. + +=cut + +sub print_headers { + my ( $self, $fh ) = @_; + return $self->print( $fh, $self->{_fields} ); +} + +=head2 format_patron + + my $row = $csv->format_patron($patron); + +Formats a Koha::Patron object into an arrayref of CSV fields based on +the configured field list. + +=cut + +sub format_patron { + my ( $self, $patron ) = @_; + + my $category = $patron->category; + my $member = $patron->unblessed; + + # Add category fields + $member->{description} = $category->description; + $member->{category_type} = $category->category_type; + + # Extract requested fields, handling undefined and reference values + my @row = map { ( defined $member->{$_} && !ref $member->{$_} ) ? $member->{$_} : '' } @{ $self->{_fields} }; + + return \@row; +} + +=head2 print_patron + + $csv->print_patron($fh, $patron); + +Formats and prints a Koha::Patron to a filehandle as a CSV row. + +=cut + +sub print_patron { + my ( $self, $fh, $patron ) = @_; + return $self->print( $fh, $self->format_patron($patron) ); +} + +1; diff --git a/misc/export_borrowers.pl b/misc/export_borrowers.pl index 26609adb1f..eb8f14d84e 100755 --- a/misc/export_borrowers.pl +++ b/misc/export_borrowers.pl @@ -20,11 +20,12 @@ # Script to export borrowers use Modern::Perl; -use Text::CSV; + use Getopt::Long qw( GetOptions :config no_ignore_case ); use Koha::Script; use C4::Context; +use Koha::CSV::Patrons; use Koha::Patrons; binmode STDOUT, ":encoding(UTF-8)"; @@ -94,38 +95,29 @@ unless ($separator) { $separator = C4::Context->csv_delimiter; } -my $csv = Text::CSV->new( { sep_char => $separator, binary => 1, formula => 'empty' } ); +my $csv = Koha::CSV::Patrons->new( { sep_char => $separator } ); # If the user did not specify any field to export, we assume they want them all # We retrieve the first borrower information to get field names my ($borrowernumber) = $sth->fetchrow_array or die "No borrower to export"; -my $patron = Koha::Patrons->find($borrowernumber); # FIXME Now is_expired is no longer available - # We will have to use Koha::Patron and allow method calls -my $category = $patron->category; -my $member = $patron->unblessed; +my $patron = Koha::Patrons->find($borrowernumber); +my $category = $patron->category; +my $member = $patron->unblessed; $member->{description} = $category->description; $member->{category_type} = $category->category_type; @fields = keys %$member unless (@fields); +# Set fields for CSV export +$csv->fields( \@fields ); + if ($show_header) { - $csv->combine(@fields); - print $csv->string . "\n"; + $csv->print_headers(*STDOUT); } -$csv->combine( map { ( defined $member->{$_} and !ref $member->{$_} ) ? $member->{$_} : '' } @fields ); -die "Invalid character at borrower $borrowernumber: [" . $csv->error_input . "]\n" - if ( !defined( $csv->string ) ); -print $csv->string . "\n"; +$csv->print_patron( *STDOUT, $patron ); while ( my $borrowernumber = $sth->fetchrow_array ) { - my $patron = Koha::Patrons->find($borrowernumber); - my $category = $patron->category; - my $member = $patron->unblessed; - $member->{description} = $category->description; - $member->{category_type} = $category->category_type; - $csv->combine( map { ( defined $member->{$_} and !ref $member->{$_} ) ? $member->{$_} : '' } @fields ); - die "Invalid character at borrower $borrowernumber: [" . $csv->error_input . "]\n" - if ( !defined( $csv->string ) ); - print $csv->string . "\n"; + my $patron = Koha::Patrons->find($borrowernumber); + $csv->print_patron( *STDOUT, $patron ); } diff --git a/t/db_dependent/Koha/CSV/Patrons.t b/t/db_dependent/Koha/CSV/Patrons.t new file mode 100755 index 0000000000..06e918d285 --- /dev/null +++ b/t/db_dependent/Koha/CSV/Patrons.t @@ -0,0 +1,138 @@ +#!/usr/bin/perl + +# Copyright 2026 Theke Solutions +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Test::More tests => 7; +use Test::NoWarnings; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +BEGIN { + use_ok('Koha::CSV::Patrons'); +} + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +subtest 'new() tests' => sub { + plan tests => 2; + + my $csv = Koha::CSV::Patrons->new(); + isa_ok( $csv, 'Koha::CSV::Patrons', 'Object created' ); + + $csv = Koha::CSV::Patrons->new( { fields => [qw( cardnumber surname firstname )] } ); + is_deeply( $csv->fields(), [qw( cardnumber surname firstname )], 'Fields set correctly' ); +}; + +subtest 'fields() tests' => sub { + plan tests => 2; + + my $csv = Koha::CSV::Patrons->new(); + is_deeply( $csv->fields(), [], 'Empty fields by default' ); + + $csv->fields( [qw( email phone )] ); + is_deeply( $csv->fields(), [qw( email phone )], 'Fields updated' ); +}; + +subtest 'format_patron() tests' => sub { + plan tests => 4; + + $schema->storage->txn_begin; + + t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' ); + + my $category = $builder->build_object( { class => 'Koha::Patron::Categories' } ); + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { + categorycode => $category->categorycode, + surname => 'Test', + firstname => 'Patron', + email => 'test@example.com', + } + } + ); + + my $csv = + Koha::CSV::Patrons->new( { fields => [qw( cardnumber surname firstname email description category_type )] } ); + + my $row = $csv->format_patron($patron); + + isa_ok( $row, 'ARRAY', 'format_patron returns arrayref' ); + is( scalar @$row, 6, 'Correct number of fields' ); + is( $row->[1], 'Test', 'Surname field correct' ); + is( $row->[2], 'Patron', 'Firstname field correct' ); + + $schema->storage->txn_rollback; +}; + +subtest 'print_headers() tests' => sub { + plan tests => 2; + + t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' ); + + my $csv = Koha::CSV::Patrons->new( { fields => [qw( cardnumber surname firstname )] } ); + + my $output = ''; + open my $fh, '>', \$output or die "Cannot open string as file: $!"; + + my $status = $csv->print_headers($fh); + ok( $status, 'print_headers returns true' ); + + close $fh; + + is( $output, "cardnumber,surname,firstname\n", 'Headers printed correctly' ); +}; + +subtest 'print_patron() tests' => sub { + plan tests => 2; + + $schema->storage->txn_begin; + + t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' ); + + my $category = $builder->build_object( { class => 'Koha::Patron::Categories' } ); + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { + categorycode => $category->categorycode, + surname => 'Smith', + firstname => 'John', + } + } + ); + + my $csv = Koha::CSV::Patrons->new( { fields => [qw( surname firstname )] } ); + + my $output = ''; + open my $fh, '>', \$output or die "Cannot open string as file: $!"; + + my $status = $csv->print_patron( $fh, $patron ); + ok( $status, 'print_patron returns true' ); + + close $fh; + + like( $output, qr/Smith,John/, 'Patron data printed correctly' ); + + $schema->storage->txn_rollback; +}; -- 2.50.1 (Apple Git-155)