From 480847e81cca832a3e8eac35753532275ec49aec Mon Sep 17 00:00:00 2001 From: Jacek Ablewicz Date: Fri, 9 May 2014 11:30:28 +0200 Subject: [PATCH] Bug 3050 - Add an option to upload scanned invoices #1/3 (part #1: new module w/ UT + script + template) New feature, adds an ability to attach arbitrary files to acquisition records (currently: to the invoices - but it can be extended to baskets, basketgroups, budgets etc.). Note: this code is (heavily) based on "Bug 8130 - attach PDF files to a patron record" by Kale M Hall, main difference being that new table (misc_files) and new module (Koha/Misc/Files.pm) are intended to be a little more generic solution - they allow to store and manage files associated with great many kinds of records, from arbitrary tables. Test plan: 1) Apply patch[es] 2) Run installer/data/mysql/updatedatabase.pl 3) Enable system preference 'AcqEnableFiles' in acquisition 4) New option 'Manage invoice files' appears in the invoice detail page 5) Upload/view/download/delete some files for some invoices 6) Try to delete invoice with files attached (files should get deleted as well) 7) Try to merge 2+ invoices with files attached; after merge, all files previously attached to individual invoices being merged should be attached to resulting invoice (merge destination) 8) prove t/db_dependent/Koha_Misc_Files.t 9) Ensure there are no regressions of any kind in invoice detail page (acqui/invoice.pl). Signed-off-by: Paola Rossi Signed-off-by: Jonathan Druart Signed-off-by: Galen Charlton --- Koha/Misc/Files.pm | 266 ++++++++++++++++++ acqui/invoice-files.pl | 121 ++++++++ .../prog/en/modules/acqui/invoice-files.tt | 100 +++++++ t/db_dependent/Koha_Misc_Files.t | 87 ++++++ 4 files changed, 574 insertions(+) create mode 100644 Koha/Misc/Files.pm create mode 100755 acqui/invoice-files.pl create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/acqui/invoice-files.tt create mode 100755 t/db_dependent/Koha_Misc_Files.t diff --git a/Koha/Misc/Files.pm b/Koha/Misc/Files.pm new file mode 100644 index 0000000000..1aaf04f298 --- /dev/null +++ b/Koha/Misc/Files.pm @@ -0,0 +1,266 @@ +package Koha::Misc::Files; + +# This file is part of Koha. +# +# Copyright 2012 Kyle M Hall +# Copyright 2014 Jacek Ablewicz +# Based on Koha/Borrower/Files.pm by Kyle M Hall +# +# 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 vars qw($VERSION); +$VERSION = '0.25'; + +use C4::Context; +use C4::Output; +use C4::Dates; + +=head1 NAME + +Koha::Misc::Files - module for managing miscellaneous files associated +with records from arbitrary tables + +=head1 SYNOPSIS + +use Koha::Misc::Files; + +my $mf = Koha::Misc::Files->new( tabletag => $tablename, + recordid => $recordnumber ); + +=head1 FUNCTIONS + +=over + +=item new() + +my $mf = Koha::Misc::Files->new( tabletag => $tablename, + recordid => $recordnumber ); + +Creates new Koha::Misc::Files object. Such object is essentially +a pair: in typical usage scenario, 'tabletag' parameter will be +a database table name, and 'recordid' an unique record ID number +from this table. However, this method does accept an arbitrary +string as 'tabletag', and an arbitrary integer as 'recordid'. + +Particular Koha::Misc::Files object can have one or more file records +(actuall file contents + various file metadata) associated with it. + +In case of an error (wrong parameter format) it returns undef. + +=cut + +sub new { + my ( $class, %args ) = @_; + + my $recid = $args{'recordid'}; + my $tag = $args{'tabletag'}; + ( defined($tag) && $tag ne '' && defined($recid) && $recid =~ /^\d+$/ ) + || return (); + + my $self = bless( {}, $class ); + + $self->{'table_tag'} = $tag; + $self->{'record_id'} = '' . ( 0 + $recid ); + + return $self; +} + +=item GetFilesInfo() + +my $files_descriptions = $mf->GetFilesInfo(); + +This method returns a reference to an array of hashes +containing files metadata (file_id, file_name, file_type, +file_description, file_size, date_uploaded) for all file records +associated with given $mf object, or an empty arrayref if there are +no such records yet. + +In case of an error it returns undef. + +=cut + +sub GetFilesInfo { + my $self = shift; + + my $dbh = C4::Context->dbh; + my $query = ' + SELECT + file_id, + file_name, + file_type, + file_description, + date_uploaded, + LENGTH(file_content) AS file_size + FROM misc_files + WHERE table_tag = ? AND record_id = ? + ORDER BY file_name, date_uploaded + '; + my $sth = $dbh->prepare($query); + $sth->execute( $self->{'table_tag'}, $self->{'record_id'} ); + return $sth->fetchall_arrayref( {} ); +} + +=item AddFile() + +$mf->AddFile( name => $filename, type => $mimetype, + description => $description, content => $content ); + +Adds a new file (we want to store for / associate with a given +object) to the database. Parameters 'name' and 'content' are mandatory. +Note: this method would (silently) fail if there is no 'name' given +or if the 'content' provided is empty. + +=cut + +sub AddFile { + my ( $self, %args ) = @_; + + my $name = $args{'name'}; + my $type = $args{'type'} // ''; + my $description = $args{'description'}; + my $content = $args{'content'}; + + return unless ( defined($name) && $name ne '' && defined($content) && $content ne '' ); + + my $dbh = C4::Context->dbh; + my $query = ' + INSERT INTO misc_files ( table_tag, record_id, file_name, file_type, file_description, file_content ) + VALUES ( ?,?,?,?,?,? ) + '; + my $sth = $dbh->prepare($query); + $sth->execute( $self->{'table_tag'}, $self->{'record_id'}, $name, $type, + $description, $content ); +} + +=item GetFile() + +my $file = $mf->GetFile( id => $file_id ); + +For an individual, specific file ID this method returns a hashref +containing all metadata (file_id, table_tag, record_id, file_name, +file_type, file_description, file_content, date_uploaded), plus +an actuall contents of a file (in 'file_content'). In typical usage +scenarios, for a given $mf object, specific file IDs have to be +obtained first by GetFilesInfo() call. + +Returns undef in case when file ID specified as 'id' parameter was not +found in the database. + +=cut + +sub GetFile { + my ( $self, %args ) = @_; + + my $file_id = $args{'id'}; + + my $dbh = C4::Context->dbh; + my $query = ' + SELECT * FROM misc_files WHERE file_id = ? AND table_tag = ? AND record_id = ? + '; + my $sth = $dbh->prepare($query); + $sth->execute( $file_id, $self->{'table_tag'}, $self->{'record_id'} ); + return $sth->fetchrow_hashref(); +} + +=item DelFile() + +$mf->DelFile( id => $file_id ); + +Deletes specific, individual file record (file contents and metadata) +from the database. + +=cut + +sub DelFile { + my ( $self, %args ) = @_; + + my $file_id = $args{'id'}; + + my $dbh = C4::Context->dbh; + my $query = ' + DELETE FROM misc_files WHERE file_id = ? AND table_tag = ? AND record_id = ? + '; + my $sth = $dbh->prepare($query); + $sth->execute( $file_id, $self->{'table_tag'}, $self->{'record_id'} ); +} + +=item DelAllFiles() + +$mf->DelAllFiles(); + +Deletes all file records associated with (stored for) a given $mf object. + +=cut + +sub DelAllFiles { + my ($self) = @_; + + my $dbh = C4::Context->dbh; + my $query = ' + DELETE FROM misc_files WHERE table_tag = ? AND record_id = ? + '; + my $sth = $dbh->prepare($query); + $sth->execute( $self->{'table_tag'}, $self->{'record_id'} ); +} + +=item MergeFileRecIds() + +$mf->MergeFileRecIds(@ids_to_be_merged); + +This method re-associates all individuall file records associated with +some "parent" records IDs (provided in @ids_to_be_merged) with the given +single $mf object (which would be treated as a "parent" destination). + +This a helper method; typically it needs to be called only in cases when +some "parent" records are being merged in the (external) 'tablename' +table. + +=cut + +sub MergeFileRecIds { + my ( $self, @ids_to_merge ) = @_; + + my $dst_recid = $self->{'record_id'}; + @ids_to_merge = map { ( $dst_recid == $_ ) ? () : ($_); } @ids_to_merge; + @ids_to_merge > 0 || return (); + + my $dbh = C4::Context->dbh; + my $query = ' + UPDATE misc_files SET record_id = ? + WHERE table_tag = ? AND record_id = ? + '; + my $sth = $dbh->prepare($query); + + for my $src_recid (@ids_to_merge) { + $sth->execute( $dst_recid, $self->{'table_tag'}, $src_recid ); + } +} + +1; + +__END__ + +=back + +=head1 SEE ALSO + +Koha::Borrower::Files + +=head1 AUTHOR + +Kyle M Hall Ekyle.m.hall@gmail.comE, +Jacek Ablewicz Eablewicz@gmail.comE + +=cut diff --git a/acqui/invoice-files.pl b/acqui/invoice-files.pl new file mode 100755 index 0000000000..635c427a0a --- /dev/null +++ b/acqui/invoice-files.pl @@ -0,0 +1,121 @@ +#!/usr/bin/perl + +# This file is part of Koha. +# +# Copyright 2014 Jacek Ablewicz +# +# 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 . + +=head1 NAME + +invoice-files.pl + +=head1 DESCRIPTION + +Manage files associated with invoice + +=cut + +use Modern::Perl; + +use CGI; +use C4::Auth; +use C4::Output; +use C4::Acquisition; +use Koha::Misc::Files; + +my $input = new CGI; +my ( $template, $loggedinuser, $cookie, $flags ) = get_template_and_user( + { + template_name => 'acqui/invoice-files.tt', + query => $input, + type => 'intranet', + authnotrequired => 0, + flagsrequired => { 'acquisition' => '*' }, + debug => 1, + } +); + +my $invoiceid = $input->param('invoiceid') // ''; +my $op = $input->param('op') // ''; +my %errors; + +my $mf = Koha::Misc::Files->new( tabletag => 'aqinvoices', recordid => $invoiceid ); +defined($mf) || do { $op = 'none'; $errors{'invalid_parameter'} = 1; }; + +if ( $op eq 'download' ) { + my $file_id = $input->param('file_id'); + my $file = $mf->GetFile( id => $file_id ); + + my $fname = $file->{'file_name'}; + my $ftype = $file->{'file_type'}; + if ($input->param('view') && ($ftype =~ m|^image/|i || $fname =~ /\.pdf/i)) { + $fname =~ /\.pdf/i && do { $ftype='application/pdf'; }; + print $input->header( + -type => $ftype, + -charset => 'utf-8' + ); + } else { + print $input->header( + -type => $file->{'file_type'}, + -charset => 'utf-8', + -attachment => $file->{'file_name'} + ); + } + print $file->{'file_content'}; +} +else { + my $details = GetInvoiceDetails($invoiceid); + $template->param( + invoiceid => $details->{'invoiceid'}, + invoicenumber => $details->{'invoicenumber'}, + suppliername => $details->{'suppliername'}, + booksellerid => $details->{'booksellerid'}, + datereceived => $details->{'datereceived'}, + ); + + if ( $op eq 'upload' ) { + my $uploaded_file = $input->upload('uploadfile'); + + if ($uploaded_file) { + my $filename = $input->param('uploadfile'); + my $mimetype = $input->uploadInfo($filename)->{'Content-Type'}; + + $errors{'empty_upload'} = 1 if ( -z $uploaded_file ); + unless (%errors) { + my $file_content = do { local $/; <$uploaded_file>; }; + if ($mimetype =~ /^application\/(force-download|unknown)$/i && $filename =~ /\.pdf$/i) { + $mimetype = 'application/pdf'; + } + $mf->AddFile( + name => $filename, + type => $mimetype, + content => $file_content, + description => $input->param('description') + ); + } + } + else { + $errors{'no_file'} = 1; + } + } elsif ( $op eq 'delete' ) { + $mf->DelFile( id => $input->param('file_id') ); + } + + $template->param( + files => (defined($mf)? $mf->GetFilesInfo(): undef), + errors => \%errors + ); + output_html_with_http_headers $input, $cookie, $template->output; +} diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/invoice-files.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/invoice-files.tt new file mode 100644 index 0000000000..112153604a --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/invoice-files.tt @@ -0,0 +1,100 @@ +[% USE KohaDates %] + +[% INCLUDE 'doc-head-open.inc' %] +Koha › Acquisitions › Invoice › Files + +[% INCLUDE 'doc-head-close.inc' %] +[% INCLUDE 'datatables.inc' %] + + + +[% INCLUDE 'header.inc' %] +[% INCLUDE 'acquisitions-search.inc' %] + + + +
+ +
+
+
+

Files for invoice: [% invoicenumber | html %]

+

Vendor: [% suppliername %]

+
+ [% IF errors %] +
+ [% IF errors.empty_upload %]The file you are attempting to upload has no contents.[% END %] + [% IF errors.no_file %]You did not select a file to upload.[% END %] + [% IF errors.invalid_parameter %]Invalid or missing script parameter.[% END %] +
+ [% END %] + [% IF files %] + + + + + + + + + + + + + + [% FOREACH f IN files %] + + + + + + + + + + [% END %] + +
NameTypeDescriptionUploadedBytes  
[% f.file_name | html %][% f.file_type | html %][% f.file_description | html %][% f.date_uploaded | $KohaDates %][% f.file_size %]DeleteDownload
+ [% ELSE %] +
+

This invoice has no files attached.

+
+ [% END %] + [% IF invoiceid %] +
+
+
+ Upload New File +
    +
  1. + + +
  2. +
  3. +
+
+
+
+ [% END %] +
+
+
+ [% INCLUDE 'acquisitions-menu.inc' %] +
+
+[% INCLUDE 'intranet-bottom.inc' %] diff --git a/t/db_dependent/Koha_Misc_Files.t b/t/db_dependent/Koha_Misc_Files.t new file mode 100755 index 0000000000..d88da5ce46 --- /dev/null +++ b/t/db_dependent/Koha_Misc_Files.t @@ -0,0 +1,87 @@ +#!/usr/bin/perl + +# Unit tests for Koha::Misc::Files +# Author: Jacek Ablewicz, abl@biblos.pk.edu.pl + +use Modern::Perl; +use C4::Context; +use Test::More tests => 27; + +BEGIN { + use_ok('Koha::Misc::Files'); +} + +my $dbh = C4::Context->dbh; +$dbh->{AutoCommit} = 0; +$dbh->{RaiseError} = 1; + +## new() parameter handling check +is(Koha::Misc::Files->new(recordid => 12), undef, "new() param check test/1"); +is(Koha::Misc::Files->new(recordid => 'aa123', tabletag => 'ttag_a'), undef, "new() param check test/2"); + +## create some test objects with arbitrary (tabletag, recordid) pairs +my $mf_a_123 = Koha::Misc::Files->new(recordid => '123', tabletag => 'tst_table_a'); +my $mf_a_124 = Koha::Misc::Files->new(recordid => '124', tabletag => 'tst_table_a'); +my $mf_b_221 = Koha::Misc::Files->new(recordid => '221', tabletag => 'tst_table_b'); +is(ref($mf_a_123), "Koha::Misc::Files", "new() returned object type"); + +## GetFilesInfo() initial tests (dummy AddFile() / parameter handling checks) +is(ref($mf_a_123->GetFilesInfo()), 'ARRAY', "GetFilesInfo() return type"); +is(scalar @{$mf_a_123->GetFilesInfo()}, 0, "GetFilesInfo() empty/non-empty result/1"); +$mf_a_123->AddFile(name => '', type => 'text/plain', content => "aaabbcc"); +is(scalar @{$mf_a_123->GetFilesInfo()}, 0, "GetFilesInfo() empty/non-empty result/2"); + +## AddFile(); add 5 sample file records for 3 test objects +$mf_a_123->AddFile(name => 'File_name_1.txt', type => 'text/plain', + content => "file contents\n1111\n", description => "File #1 sample description"); +$mf_a_123->AddFile(name => 'File_name_2.txt', type => 'text/plain', + content => "file contents\n2222\n", description => "File #2 sample description"); +$mf_a_124->AddFile(name => 'File_name_3.txt', content => "file contents\n3333\n", type => 'text/whatever'); +$mf_a_124->AddFile(name => 'File_name_4.txt', content => "file contents\n4444\n"); +$mf_b_221->AddFile(name => 'File_name_5.txt', content => "file contents\n5555\n"); + +## check GetFilesInfo() results for added files +my $files_a_123_infos = $mf_a_123->GetFilesInfo(); +is(scalar @$files_a_123_infos, 2, "GetFilesInfo() result count/1"); +is(scalar @{$mf_b_221->GetFilesInfo()}, 1, "GetFilesInfo() result count/2"); +is(ref($files_a_123_infos->[0]), 'HASH', "GetFilesInfo() item file result type"); +is($files_a_123_infos->[0]->{file_name}, 'File_name_1.txt', "GetFilesInfo() result check/1"); +is($files_a_123_infos->[1]->{file_name}, 'File_name_2.txt', "GetFilesInfo() result check/2"); +is($files_a_123_infos->[1]->{file_type}, 'text/plain', "GetFilesInfo() result check/3"); +is($files_a_123_infos->[1]->{file_size}, 19, "GetFilesInfo() result check/4"); +is($files_a_123_infos->[1]->{file_description}, 'File #2 sample description', "GetFilesInfo() result check/5"); + +## GetFile() result checks +is($mf_a_123->GetFile(), undef, "GetFile() result check/1"); +is($mf_a_123->GetFile(id => 0), undef, "GetFile() result check/2"); + +my $a123_file_1 = $mf_a_123->GetFile(id => $files_a_123_infos->[0]->{file_id}); +is(ref($a123_file_1), 'HASH', "GetFile() result check/3"); +is($a123_file_1->{file_id}, $files_a_123_infos->[0]->{file_id}, "GetFile() result check/4"); +is($a123_file_1->{file_content}, "file contents\n1111\n", "GetFile() result check/5"); + +## MergeFileRecIds() tests +$mf_a_123->MergeFileRecIds(123,221); +$files_a_123_infos = $mf_a_123->GetFilesInfo(); +is(scalar @$files_a_123_infos, 2, "GetFilesInfo() result count after dummy MergeFileRecIds()"); +$mf_a_123->MergeFileRecIds(124); +$files_a_123_infos = $mf_a_123->GetFilesInfo(); +is(scalar @$files_a_123_infos, 4, "GetFilesInfo() result count after MergeFileRecIds()/1"); +is(scalar @{$mf_a_124->GetFilesInfo()}, 0, "GetFilesInfo() result count after MergeFileRecIds()/2"); +is($files_a_123_infos->[-1]->{file_name}, 'File_name_4.txt', "GetFilesInfo() result check after MergeFileRecIds()"); + +## DelFile() test +$mf_a_123->DelFile(id => $files_a_123_infos->[-1]->{file_id}); +$files_a_123_infos = $mf_a_123->GetFilesInfo(); +is(scalar @$files_a_123_infos, 3, "GetFilesInfo() result count after DelFile()"); + +## DelAllFiles() tests +$mf_a_123->DelAllFiles(); +$files_a_123_infos = $mf_a_123->GetFilesInfo(); +is(scalar @$files_a_123_infos, 0, "GetFilesInfo() result count after DelAllFiles()/1"); +$mf_b_221->DelAllFiles(); +is(scalar @{$mf_b_221->GetFilesInfo()}, 0, "GetFilesInfo() result count after DelAllFiles()/2"); + +$dbh->rollback; + +1; -- 2.39.5