From f2fa8c018371ef4e6fee5f62ad29b31db7cd2aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Cohen=20Arazi?= Date: Wed, 14 Jan 2026 16:54:10 -0300 Subject: [PATCH] Bug 41620: Introduce Koha::CSV::ItemSearch This patch adds Koha::CSV::ItemSearch, a specialized CSV generator for item search results with predefined headers and formatting. The class: - Extends Koha::CSV with always_quote enabled for Excel compatibility - Provides 21 predefined column headers for item search exports - Formats Koha::Item objects into CSV-ready field arrays - Handles authorized values, dates, and library names - Supports MARC21 and UNIMARC title formatting This replaces the template-based CSV generation in itemsearch.pl, eliminating spacing issues and providing a testable, maintainable approach to item search CSV exports. Test plan: 1. Apply patch 2. Run: $ ktd --shell k$ prove t/db_dependent/Koha/CSV/ItemSearch.t => SUCCESS: Tests pass! 3. Tests cover: - Object initialization with always_quote enabled - Header generation (21 columns) - Item formatting with all field types - Authorized value lookups - Date formatting - Library name resolution - CSV output to filehandle 4. Sign off :-D --- Koha/CSV/ItemSearch.pm | 306 +++++++++++++++++++++++++++ t/db_dependent/Koha/CSV/ItemSearch.t | 141 ++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 Koha/CSV/ItemSearch.pm create mode 100755 t/db_dependent/Koha/CSV/ItemSearch.t diff --git a/Koha/CSV/ItemSearch.pm b/Koha/CSV/ItemSearch.pm new file mode 100644 index 0000000000..ad23219224 --- /dev/null +++ b/Koha/CSV/ItemSearch.pm @@ -0,0 +1,306 @@ +package Koha::CSV::ItemSearch; + +# 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 C4::Context; +use Koha::DateUtils qw( dt_from_string output_pref ); +use Koha::I18N qw( __ ); + +use base 'Koha::CSV'; + +=head1 NAME + +Koha::CSV::ItemSearch - CSV generator for item search results + +=head1 SYNOPSIS + + my $csv = Koha::CSV::ItemSearch->new(); + + # Print headers + $csv->print_headers($fh); + + # Print item rows + foreach my $item (@items) { + $csv->print_item($fh, $item); + } + +=head1 DESCRIPTION + +Specialized CSV generator for item search results with predefined headers +and formatting. + +=cut + +=head2 new + + my $csv = Koha::CSV::ItemSearch->new(); + +Creates a new Koha::CSV::ItemSearch object with always_quote enabled +for Excel compatibility. + +=cut + +sub new { + my ( $class, $params ) = @_; + $params->{always_quote} = 1; + return $class->SUPER::new($params); +} + +=head2 headers + +Returns arrayref of column headers for item search CSV export. + +=cut + +sub headers { + return [ + __('Title'), + __('Publication date'), + __('Publisher'), + __('Collection'), + __('Barcode'), + __('Item number'), + __('Serial enumeration'), + __('Call number'), + __('Home library'), + __('Current library'), + __('Shelving location'), + __('Item type'), + __('Inventory number'), + __('Not for loan status'), + __('Lost status'), + __('Withdrawn status'), + __('Damaged status'), + __('Date accessioned'), + __('Checkouts'), + __('Last checkout date'), + __('Due date'), + ]; +} + +=head2 print_headers + + $csv->print_headers($fh); + +Prints the CSV headers to a filehandle. + +=cut + +sub print_headers { + my ( $self, $fh ) = @_; + return $self->print( $fh, $self->headers ); +} + +=head2 format_item + + my $fields = $csv->format_item($item); + +Formats a Koha::Item object into an arrayref of CSV fields. + +=cut + +sub format_item { + my ( $self, $item ) = @_; + + my $biblio = $item->biblio; + my $biblioitem = $item->biblioitem; + + return [ + $self->_format_title($item), + $biblioitem->publicationyear || $biblio->copyrightdate || '', + $biblioitem->publishercode || '', + $self->_format_collection($item), + $item->barcode || '', + $item->itemnumber || '', + $item->enumchron || '', + $item->itemcallnumber || '', + $self->_format_branch( $item->homebranch ), + $self->_format_branch( $item->holdingbranch ), + $self->_format_location($item), + $self->_format_itemtype($item), + $item->stocknumber || '', + $self->_format_notforloan($item), + $self->_format_itemlost($item), + $self->_format_withdrawn($item), + $self->_format_damaged($item), + $self->_format_date( $item->dateaccessioned ), + $item->issues || 0, + $self->_format_date( $item->datelastborrowed ), + $self->_format_due_date($item), + ]; +} + +=head2 print_item + + $csv->print_item($fh, $item); + +Formats and prints a Koha::Item to a filehandle as a CSV row. + +=cut + +sub print_item { + my ( $self, $fh, $item ) = @_; + return $self->print( $fh, $self->format_item($item) ); +} + +# Private formatting methods + +sub _format_title { + my ( $self, $item ) = @_; + my $biblio = $item->biblio; + my $title = $biblio->title || ''; + my $author = $biblio->author || ''; + + if ( C4::Context->preference('marcflavour') eq 'UNIMARC' && $author ) { + return "$title by $author"; + } + return $author ? "$title $author" : $title; +} + +sub _format_collection { + my ( $self, $item ) = @_; + return '' unless $item->ccode; + + require Koha::AuthorisedValues; + my $av = Koha::AuthorisedValues->search( + { + category => 'CCODE', + authorised_value => $item->ccode, + } + )->next; + + return $av ? $av->lib : $item->ccode; +} + +sub _format_branch { + my ( $self, $branchcode ) = @_; + return '' unless $branchcode; + + require Koha::Libraries; + my $library = Koha::Libraries->find($branchcode); + return $library ? $library->branchname : $branchcode; +} + +sub _format_location { + my ( $self, $item ) = @_; + return '' unless $item->location; + + require Koha::AuthorisedValues; + my $av = Koha::AuthorisedValues->search( + { + category => 'LOC', + authorised_value => $item->location, + } + )->next; + + return $av ? $av->lib : $item->location; +} + +sub _format_itemtype { + my ( $self, $item ) = @_; + return '' unless $item->itype; + + require Koha::ItemTypes; + my $itemtype = Koha::ItemTypes->find( $item->itype ); + return $itemtype ? $itemtype->description : $item->itype; +} + +sub _format_notforloan { + my ( $self, $item ) = @_; + return '' unless defined $item->notforloan; + + require Koha::AuthorisedValues; + my $av = Koha::AuthorisedValues->search( + { + category => 'NOT_LOAN', + authorised_value => $item->notforloan, + } + )->next; + + return $av ? $av->lib : $item->notforloan; +} + +sub _format_itemlost { + my ( $self, $item ) = @_; + return '' unless $item->itemlost; + + require Koha::AuthorisedValues; + my $av = Koha::AuthorisedValues->search( + { + category => 'LOST', + authorised_value => $item->itemlost, + } + )->next; + + return $av ? $av->lib : ''; +} + +sub _format_withdrawn { + my ( $self, $item ) = @_; + return '' unless $item->withdrawn; + + require Koha::AuthorisedValues; + my $av = Koha::AuthorisedValues->search( + { + category => 'WITHDRAWN', + authorised_value => $item->withdrawn, + } + )->next; + + return $av ? $av->lib : ''; +} + +sub _format_damaged { + my ( $self, $item ) = @_; + return '' unless $item->damaged; + + require Koha::AuthorisedValues; + my $av = Koha::AuthorisedValues->search( + { + category => 'DAMAGED', + authorised_value => $item->damaged, + } + )->next; + + return $av ? $av->lib : ''; +} + +sub _format_date { + my ( $self, $date ) = @_; + return '' unless $date; + + return output_pref( + { + dt => dt_from_string($date), + dateformat => 'iso', + dateonly => 1, + } + ); +} + +sub _format_due_date { + my ( $self, $item ) = @_; + my $checkout = $item->checkout; + return '' unless $checkout; + + return $self->_format_date( $checkout->date_due ); +} + +1; diff --git a/t/db_dependent/Koha/CSV/ItemSearch.t b/t/db_dependent/Koha/CSV/ItemSearch.t new file mode 100755 index 0000000000..d350702dc8 --- /dev/null +++ b/t/db_dependent/Koha/CSV/ItemSearch.t @@ -0,0 +1,141 @@ +#!/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::ItemSearch'); +} + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +subtest 'new() tests' => sub { + plan tests => 2; + + my $csv = Koha::CSV::ItemSearch->new(); + isa_ok( $csv, 'Koha::CSV::ItemSearch', 'Object created' ); + is( $csv->always_quote, 1, 'always_quote is enabled for Excel compatibility' ); +}; + +subtest 'headers() tests' => sub { + plan tests => 2; + + my $csv = Koha::CSV::ItemSearch->new(); + my $headers = $csv->headers(); + + isa_ok( $headers, 'ARRAY', 'headers returns arrayref' ); + is( scalar @$headers, 21, 'headers has 21 columns' ); +}; + +subtest 'format_item() tests' => sub { + plan tests => 6; + + $schema->storage->txn_begin; + + t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' ); + + # Create test data + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + my $biblio = $builder->build_sample_biblio( + { + title => 'Test Title', + author => 'Test Author', + } + ); + my $item = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + homebranch => $library->branchcode, + holdingbranch => $library->branchcode, + barcode => '12345678', + } + ); + + my $csv = Koha::CSV::ItemSearch->new(); + my $fields = $csv->format_item($item); + + isa_ok( $fields, 'ARRAY', 'format_item returns arrayref' ); + is( scalar @$fields, 21, 'format_item returns 21 fields' ); + + # Check specific fields + like( $fields->[0], qr/Test Title/, 'Title field contains title' ); + is( $fields->[4], '12345678', 'Barcode field correct' ); + is( $fields->[5], $item->itemnumber, 'Item number field correct' ); + is( $fields->[8], $library->branchname, 'Home library field correct' ); + + $schema->storage->txn_rollback; +}; + +subtest 'print_headers() tests' => sub { + plan tests => 2; + + t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' ); + + my $csv = Koha::CSV::ItemSearch->new(); + + 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; + + like( $output, qr/^"Title","Publication date"/, 'Headers printed correctly' ); +}; + +subtest 'print_item() tests' => sub { + plan tests => 2; + + $schema->storage->txn_begin; + + t::lib::Mocks::mock_preference( 'CSVDelimiter', ',' ); + + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + my $biblio = $builder->build_sample_biblio( { title => 'Test Book' } ); + my $item = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + homebranch => $library->branchcode, + holdingbranch => $library->branchcode, + barcode => '99999999', + } + ); + + my $csv = Koha::CSV::ItemSearch->new(); + + my $output = ''; + open my $fh, '>', \$output or die "Cannot open string as file: $!"; + + my $status = $csv->print_item( $fh, $item ); + ok( $status, 'print_item returns true' ); + + close $fh; + + like( $output, qr/Test Book/, 'Item data printed correctly' ); + + $schema->storage->txn_rollback; +}; -- 2.50.1 (Apple Git-155)