--- /dev/null
+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 <http://www.gnu.org/licenses>.
+
+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 E<lt>kyle.m.hall@gmail.comE<gt>,
+Jacek Ablewicz E<lt>ablewicz@gmail.comE<gt>
+
+=cut
--- /dev/null
+#!/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 <http://www.gnu.org/licenses>.
+
+=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;
+}
--- /dev/null
+[% USE KohaDates %]
+
+[% INCLUDE 'doc-head-open.inc' %]
+<title>Koha › Acquisitions › Invoice › Files</title>
+<link rel="stylesheet" type="text/css" href="[% themelang %]/css/datatables.css" />
+[% INCLUDE 'doc-head-close.inc' %]
+[% INCLUDE 'datatables.inc' %]
+<script type="text/javascript">
+//<![CDATA[
+ $(document).ready(function() {
+ $("#invoice_files_details_table").dataTable($.extend(true, {}, dataTablesDefaults, {
+ "aoColumnDefs": [
+ { "aTargets": [ -1, -2 ], "bSortable": false, "bSearchable": false },
+ { "aTargets": [ 3 ], "sType": "natural" }
+ ],
+ bInfo: false,
+ bPaginate: false,
+ bFilter: false,
+ sDom: "t"
+ }));
+ });
+//]]>
+</script>
+</head>
+<body>
+[% INCLUDE 'header.inc' %]
+[% INCLUDE 'acquisitions-search.inc' %]
+
+<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> › <a href="/cgi-bin/koha/acqui/acqui-home.pl">Acquisitions</a> › <a href="/cgi-bin/koha/acqui/invoices.pl">Invoices</a> › <a href="/cgi-bin/koha/acqui/invoice.pl?invoiceid=[% invoiceid %]">[% invoicenumber %]</a> › Files</div>
+
+<div id="doc3" class="yui-t2">
+
+<div id="bd">
+ <div id="yui-main">
+ <div class="yui-b">
+ <h2>Files for invoice: [% invoicenumber | html %]</h2>
+ <p><b>Vendor: </b><a href="/cgi-bin/koha/acqui/supplier.pl?booksellerid=[% booksellerid %]">[% suppliername %]</a></p>
+ <br />
+ [% IF errors %]
+ <div class="dialog alert">
+ [% 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 %]
+ </div>
+ [% END %]
+ [% IF files %]
+ <table id="invoice_files_details_table">
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>Type</th>
+ <th>Description</th>
+ <th>Uploaded</th>
+ <th>Bytes</th>
+ <th> </th>
+ <th> </th>
+ </tr>
+ </thead>
+ <tbody>
+ [% FOREACH f IN files %]
+ <tr>
+ <td><a href="?invoiceid=[% invoiceid %]&op=download&view=1&file_id=[% f.file_id %]">[% f.file_name | html %]</a></td>
+ <td>[% f.file_type | html %]</td>
+ <td>[% f.file_description | html %]</td>
+ <td><!-- [% f.date_uploaded %] -->[% f.date_uploaded | $KohaDates %]</td>
+ <td>[% f.file_size %]</td>
+ <td><a href="?invoiceid=[% invoiceid %]&op=delete&file_id=[% f.file_id %]">Delete</a></td>
+ <td><a href="?invoiceid=[% invoiceid %]&op=download&file_id=[% f.file_id %]">Download</a></td>
+ </tr>
+ [% END %]
+ </tbody>
+ </table>
+ [% ELSE %]
+ <div class="dialog message">
+ <p>This invoice has no files attached.</p>
+ </div>
+ [% END %]
+ [% IF invoiceid %]
+ <br />
+ <form method="post" action="/cgi-bin/koha/acqui/invoice-files.pl" enctype="multipart/form-data">
+ <fieldset class="rows">
+ <legend>Upload New File</legend>
+ <ol>
+ <li><input type="hidden" name="op" value="upload" />
+ <input type="hidden" name="invoiceid" value="[% invoiceid %]" />
+ <label for="description">Description:</label>
+ <input name="description" id="description" type="text" /></li>
+ <li><label for="uploadfile">File:</label><input name="uploadfile" type="file" id="uploadfile" /></li>
+ </ol>
+ <fieldset class="action"><input name="upload" type="submit" id="upload" value="Upload File" /></fieldset>
+ </fieldset>
+ </form>
+ [% END %]
+ </div>
+ </div>
+ <div class="yui-b">
+ [% INCLUDE 'acquisitions-menu.inc' %]
+ </div>
+</div>
+[% INCLUDE 'intranet-bottom.inc' %]
--- /dev/null
+#!/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;