From 54585851562e27cf217c66ea42afcb761801515d Mon Sep 17 00:00:00 2001 From: Martin Persson Date: Fri, 19 Jun 2015 21:36:27 +0200 Subject: [PATCH] Bug 14367: Add MARC record history This is a proof-of-concept implementation for adding history support to MARC records. Every time a change is made a complete copy of the record is stored along with the date/time and user identity. The changes are listed under each field in the MARC record editor and can be reverted with a click. The changes are stored as a JSON array in a new column named 'history' in the database. The array is re-read from the database before updating the record to prevent old data lingering in the session from overwriting newer changes made by other users. If we decide to implement this feature it might be better to simply create a new table altogether and link it rather than the clumsy JSON solution. That would eliminate a lot of bulky code that transforms MARC-KOHA-JSON and back while ensuring data integrity. Also, there are plans to add permissions to the MARC records; this likely requires more complex interactions that will scale badly with the current JSON solution. At present, the history is hardcoded to 10 entries. This can easily be made into a syspref. The current implementation should probably be refactored into a 'BiblioHistory' class before deploying. Documentation of the functions/methods are also needed. Icon is ugly and needs to be improved. Sponsored-By: Halland County Library & Catalyst IT Test plan: * Update the database, update the schema files, restart services * Log into Staff interface, search for a title, choose to edit it's MARC record. * Chose a MARC field and modify it, press Save. * Open the MARC editor again for the same title. * Next to the edited field a new icon should appear, looking like a clock face with a encircling arrow; the history icon. * Clicking the icon should open a table showing all changes done to the record, including value change, date/time and user name. * Clicking a record in the history log should revert that field to the clicked value (a previous value) * Run prove t/db_dependent/BiblioHistory.t and confirm all tests pass Signed-off-by: David Nind Signed-off-by: David Nind Signed-off-by: David Nind --- C4/Biblio.pm | 25 +- Koha/MetadataRecord/History.pm | 261 ++++++++++++++++++ cataloguing/addbiblio.pl | 42 ++- .../bug_14367_-_add_history_column.pl | 26 ++ installer/data/mysql/kohastructure.sql | 2 + .../intranet-tmpl/prog/css/addbiblio.css | 22 ++ .../prog/en/modules/cataloguing/addbiblio.tt | 54 +++- .../intranet-tmpl/prog/img/icon-history.png | Bin 0 -> 513 bytes koha-tmpl/intranet-tmpl/prog/js/cataloging.js | 10 + t/db_dependent/BiblioHistory.t | 249 +++++++++++++++++ 10 files changed, 670 insertions(+), 21 deletions(-) create mode 100644 Koha/MetadataRecord/History.pm create mode 100644 installer/data/mysql/atomicupdate/bug_14367_-_add_history_column.pl create mode 100644 koha-tmpl/intranet-tmpl/prog/img/icon-history.png create mode 100644 t/db_dependent/BiblioHistory.t diff --git a/C4/Biblio.pm b/C4/Biblio.pm index 9790febdaa..0a1cf6ece0 100644 --- a/C4/Biblio.pm +++ b/C4/Biblio.pm @@ -84,6 +84,7 @@ use MARC::File::USMARC; use MARC::File::XML; use POSIX qw( strftime ); use Module::Load::Conditional qw( can_load ); +use JSON qw(encode_json decode_json); use C4::Koha; use C4::Log qw( logaction ); # logaction @@ -112,6 +113,7 @@ use Koha::SearchEngine; use Koha::SearchEngine::Indexer; use Koha::Libraries; use Koha::Util::MARC; +use Koha::MetadataRecord::History; =head1 NAME @@ -404,9 +406,9 @@ sub ModBiblio { # update biblionumber and biblioitemnumber in MARC # FIXME - this is assuming a 1 to 1 relationship between # biblios and biblioitems - my $sth = $dbh->prepare("select biblioitemnumber from biblioitems where biblionumber=?"); + my $sth = $dbh->prepare("SELECT biblioitems.biblioitemnumber, biblio_metadata.history FROM biblioitems INNER JOIN biblio_metadata ON biblioitems.biblionumber = biblio_metadata.biblionumber WHERE biblioitems.biblionumber=?"); $sth->execute($biblionumber); - my ($biblioitemnumber) = $sth->fetchrow; + my ($biblioitemnumber, $history) = $sth->fetchrow; $sth->finish(); _koha_marc_update_bib_ids( $record, $frameworkcode, $biblionumber, $biblioitemnumber ); @@ -417,7 +419,7 @@ sub ModBiblio { _koha_marc_update_biblioitem_cn_sort( $record, $oldbiblio, $frameworkcode ); # update the MARC record (that now contains biblio and items) with the new record data - ModBiblioMarc( $record, $biblionumber, { skip_record_index => $skip_record_index } ); + ModBiblioMarc( $record, $biblionumber, { skip_record_index => $skip_record_index, history => $history } ); # modify the other koha tables _koha_modify_biblio( $dbh, $oldbiblio, $frameworkcode ); @@ -2716,6 +2718,7 @@ sub ModBiblioMarc { } my $skip_record_index = $params->{skip_record_index} || 0; + my $history = $params->{history} || undef; # Clone record as it gets modified $record = $record->clone(); @@ -2781,6 +2784,22 @@ sub ModBiblioMarc { $indexer->index_records( $biblionumber, "specialUpdate", "biblioserver" ); } + # Decode JSON history, create new record, update and re-encode to JSON. + my $historylength = 10; + eval { + $history = decode_json($history); + 1; + } or do { + $history = undef; + }; + my $newrecord = HistoryRecordNew($record); + my $newhistory = HistoryUpdate($newrecord, $historylength, $history); + $newhistory = encode_json($newhistory); + + $m_rs->update({ metadata => $record->as_xml_record($encoding), history => $newhistory }); + + ModZebra( $biblionumber, "specialUpdate", "biblioserver", $record ); + return $biblionumber; } diff --git a/Koha/MetadataRecord/History.pm b/Koha/MetadataRecord/History.pm new file mode 100644 index 0000000000..6db53ea113 --- /dev/null +++ b/Koha/MetadataRecord/History.pm @@ -0,0 +1,261 @@ +package Koha::MetadataRecord::History; + +# Copyright 2016 Aleisha Amohia +# +# 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 strict; +use warnings; + +use C4::Charset qw( StripNonXmlChars ); +use JSON qw(decode_json); + +use vars qw(@ISA @EXPORT); + +BEGIN { + require Exporter; + @ISA = qw( Exporter ); + + push @EXPORT, qw( + &GetMarcBiblioHistory + &HistoryUpdate + &HistoryParse + &HistoryMapUsers + &HistoryBorrowerDetails + &HistoryRecordNew + ); + } + +=head1 NAME + +Koha::MetadataRecord::History - tracking changes to MARC records + +=head1 DESCRIPTION + +Koha::MetadataRecord::History not only tracks changes to MARC records and presents them in a log, but also allows users to +roll them back, either in batch to a previous point in the history or separately for each field (so you can roll back changes +to a field without changing fields that may have been changed after this one). + +=head2 GetMarcBiblioHistory + + my ($record, $history) = GetMarcBiblio($biblionumber, [$embeditems]); + +Returns MARC::Record representing bib identified by C<$biblionumber>. If no bib exists, returns undef. +C<$biblionumber>. If no bib exists, returns undef. +C<$embeditems>. If set to true, items data are included. +The MARC record contains biblio data, and items data if $embeditems is set to true. + +=cut + +sub GetMarcBiblioHistory { + my $biblionumber = shift; + my $embeditems = shift || 0; + if (not defined $biblionumber) { + warn "GetMarcBiblio called with undefined biblionumber"; + return; + } + my $dbh = C4::Context->dbh; + my $sth = $dbh->prepare("SELECT metadata,history FROM biblio_metadata WHERE biblionumber=? "); + $sth->execute($biblionumber); + my $row = $sth->fetchrow_hashref; + my $marcxml = StripNonXmlChars( $row->{'metadata'} ); + MARC::File::XML->default_record_format( C4::Context->preference('marcflavour') ); + my $record = MARC::Record->new(); + my ($history, $borrowers) = HistoryParse( $row->{'history'} ); + $borrowers = HistoryBorrowerDetails( $borrowers ); + $history = HistoryMapUsers( $history, $borrowers ); + if ($marcxml) { + $record = eval { MARC::Record::new_from_xml( $marcxml, "utf8", C4::Context->preference('marcflavour') ) }; + if ($@) { warn " problem with :$biblionumber : $@ \n$marcxml"; } + return unless $record; + C4::Biblio::_koha_marc_update_bib_ids($record, '', $biblionumber, $biblionumber); + C4::Biblio::EmbedItemsInMarcBiblio($record, $biblionumber) if ($embeditems); + return ($record, $history); + } else { + return; + } +} + +=head2 HistoryParse + +Input format (JSON): [ { datetime, borrowernumber, marcxml } ] +Output format: { tag } { subfield } [ { borrowernumber, data, datetime } ] + +=cut + +sub HistoryParse { + my $json = shift; + if (!defined $json) { + warn "HistoryParse: Missing JSON parameter"; + return; + } + my $input = decode_json($json); + # Reindex the dataset; order all values by datetime under each tag name. + my $borrowers = { }; + my $output = { }; + for my $item (@{$input}) { + my $datetime = $item->{datetime}; + $borrowers->{ $item->{borrowernumber }} = undef; + my $record = MARC::File::XML::decode($item->{marcxml}); + for my $field ($record->fields()) { + my $tag = $field->tag(); + $output->{$tag} = {} unless defined $output->{$tag}; + for my $subfield ($field->subfields()) { + # Build hash ref structure. + my $subfield_tag = @{$subfield}[0]; + $output->{$tag}->{$subfield_tag} = [] + unless defined $output->{$tag}->{$subfield_tag}; + push( @{ $output->{$tag}->{$subfield_tag} }, HistoryParsedRecordNew( + @{ $subfield }[1], $item->{borrowernumber}, $datetime)); + } + } + } + return ( $output, $borrowers ); +} + +=head2 HistoryBorrowerDetails + +Take a hashref with borrowernumbers as keys, returns new hash with the +borrowernumber as key to hash containing firstname, surname and title. + +=cut + +sub HistoryBorrowerDetails { + my $borrowernumbers = shift; + my $dbh = C4::Context->dbh; + my $output = { }; + my @numbers = keys %{$borrowernumbers}; + if (@numbers > 0) { + # We now need to look up the numeric user id's and map to name and title strings. + my $sth = $dbh->prepare(qq{SELECT + borrowernumber, title, firstname, surname + FROM borrowers + WHERE borrowernumber IN} + . '(' . join(',', ('?') x @numbers) . ')'); + $sth->execute(@numbers) + or die ('BorrowerDetails: Unable to execute SELECT statement'); + while (my $row = $sth->fetchrow_hashref()) { + $output->{ $row->{borrowernumber} } = { }; + $output->{ $row->{borrowernumber} }->{title} = $row->{title}; + $output->{ $row->{borrowernumber} }->{firstname} = $row->{firstname}; + $output->{ $row->{borrowernumber} }->{surname} = $row->{surname}; + } + $sth->finish(); + } + return $output; +} + +=head2 HistoryMapUsers + +=cut + +sub HistoryMapUsers { + my ($history, $borrowers) = @_; + my $mappedhistory = { }; + for my $tag (keys %{ $history }) { + $mappedhistory->{ $tag } = { }; + for my $field (keys %{ $history->{ $tag } }) { + $mappedhistory->{ $tag }->{ $field } = [ ]; + for my $entry (@{ $history->{ $tag }->{ $field } }) { + # C4::Dates does not allow me to print time, only dates. + #my $newtime = C4::Dates->new( $history->{ $tag }->{ $field }->{ $time }->{timestamp}, 'iso' )->output('syspref'); + my $newitem = { }; + my $user = $borrowers->{ $entry->{borrowernumber} }; + + # Maybe change this to work on original instead of copy? + $newitem->{title} = $user->{title}; + $newitem->{firstname} = $user->{firstname}; + $newitem->{surname} = $user->{surname}; + $newitem->{borrowernumber} = $entry->{borrowernumber}; + $newitem->{data} = $entry->{data}; + $newitem->{datetime} = $entry->{datetime}; + + push ( @{ $mappedhistory->{ $tag }->{ $field } } , $newitem); + } + } + } + return $mappedhistory; +} + +=head2 HistoryParsedRecordNew + +=cut + +sub HistoryParsedRecordNew { + my ($value, $borrowernumber, $datetime) = @_; + my $newrecord = { + data => $value, + borrowernumber => $borrowernumber, + datetime => $datetime, + }; + return $newrecord; +} + +=head2 HistoryRecordNew + +=cut + +sub HistoryRecordNew { + my ( $record ) = @_; + my $encoding = C4::Context->preference("marcflavour"); + my $hash_record = { + borrowernumber => C4::Context->userenv()->{number}, + datetime => POSIX::strftime('%Y-%m-%d %T', localtime(time)), + marcxml => $record->as_xml_record($encoding), + }; + return $hash_record; +} + +=head2 HistoryUpdate + +=cut + +sub HistoryUpdate { + my ( $record, $limit, $collection ) = @_; + if ( !$record ) { + warn "HistoryUpdate: Called with undefined record"; + return; + } + if ( ref($record) ne 'HASH' ) { + warn "HistoryUpdate: Record parameter is not a hashref"; + return; + } + $limit = 10 unless defined $limit; + # We need something to append history to, even if there is none. + $collection = [] unless defined $collection; + # Truncate history to $histsize - 1, need space for new entry. + splice(@{$collection}, $limit - 1); + push(@{$collection}, $record); + # Sort the resulting array, newest entries first. + # Removed; used to be integer timestamps. Is now MySQL date strings. + #@{$collection} = sort { $a->{timestamp} < $b->{timestamp} } @{$collection}; + @{$collection} = sort { $a->{datetime} lt $b->{datetime} } @{$collection}; + return $collection; +} + +1; + +__END__ + +=head1 AUTHOR + +Koha Development Team + +Paul POULAIN paul.poulain@free.fr + +Joshua Ferraro jmf@liblime.com + +=cut diff --git a/cataloguing/addbiblio.pl b/cataloguing/addbiblio.pl index 9711a892c6..b7fd4943e1 100755 --- a/cataloguing/addbiblio.pl +++ b/cataloguing/addbiblio.pl @@ -47,12 +47,12 @@ use Koha::BiblioFrameworks; use Koha::DateUtils qw( dt_from_string ); use Koha::Biblios; +use C4::Koha; use Koha::ItemTypes; +use Koha::MetadataRecord::History; use Koha::Libraries; - use Koha::BiblioFrameworks; use Koha::Patrons; - use MARC::File::USMARC; use MARC::File::XML; use URI::Escape qw( uri_escape_utf8 ); @@ -288,10 +288,14 @@ sub GetMandatoryFieldZ3950 { =cut sub create_input { - my ( $tag, $subfield, $value, $index_tag, $rec, $authorised_values_sth,$cgi ) = @_; + my ( $tag, $subfield, $value, $index_tag, $rec, $authorised_values_sth,$cgi, $history ) = @_; my $index_subfield = CreateKey(); # create a specifique key for each subfield + my $taghistory = $history->{$tag}->{$subfield}; + + $value =~ s/"/"/g; + # Apply optional framework default value when it is a new record, # or when editing as new (duplicating a record), # or when changing a record's framework, @@ -343,6 +347,7 @@ sub create_input { value => $value, maxlength => $tagslib->{$tag}->{$subfield}->{maxlength}, random => CreateKey(), + history => $taghistory, ); if(exists $mandatory_z3950->{$tag.$subfield}){ @@ -401,6 +406,7 @@ sub create_input { maxlength => $subfield_data{maxlength}, readonly => ($is_readonly) ? 1 : 0, authtype => $tagslib->{$tag}->{$subfield}->{authtypecode}, + history => $taghistory, }; # it's a plugin field @@ -423,6 +429,7 @@ sub create_input { javascript => $plugin->javascript, plugin => $plugin->name, noclick => $plugin->noclick, + history => $taghistory, }; } else { warn $plugin->errstr; @@ -435,6 +442,7 @@ sub create_input { size => 67, maxlength => $subfield_data{maxlength}, readonly => 0, + history => $taghistory, }; } @@ -447,6 +455,7 @@ sub create_input { value => $value, size => 67, maxlength => $subfield_data{maxlength}, + history => $taghistory, }; } @@ -467,6 +476,7 @@ sub create_input { id => $subfield_data{id}, name => $subfield_data{id}, value => $value, + history => $taghistory, }; } @@ -479,6 +489,7 @@ sub create_input { size => 67, maxlength => $subfield_data{maxlength}, readonly => 0, + history => $taghistory, }; } @@ -504,11 +515,11 @@ sub format_indicator { } sub build_tabs { - my ( $template, $record, $dbh, $encoding,$input ) = @_; + my ( $template, $record, $dbh, $encoding, $input, $history ) = @_; # fill arrays my @loop_data = (); - my $tag; + #my $tag; my $branch_limit = C4::Context->userenv ? C4::Context->userenv->{"branch"} : ""; my $query = "SELECT authorised_value, lib @@ -564,7 +575,6 @@ sub build_tabs { } # loop through each field foreach my $field (@fields) { - my @subfields_data; if ( $tag < 10 ) { my ( $value, $subfield ); @@ -584,7 +594,7 @@ sub build_tabs { @subfields_data, &create_input( $tag, $subfield, $value, $index_tag, $record, - $authorised_values_sth,$input + $authorised_values_sth,$input, $history ) ); } @@ -599,7 +609,7 @@ sub build_tabs { @subfields_data, &create_input( $tag, $subfield, $value, $index_tag, - $record, $authorised_values_sth,$input + $record, $authorised_values_sth,$input, $history ) ); } @@ -629,7 +639,7 @@ sub build_tabs { @subfields_data, &create_input( $tag, $subfield, '', $index_tag, $record, - $authorised_values_sth,$input + $authorised_values_sth,$input, $history ) ); } @@ -685,7 +695,7 @@ sub build_tabs { @subfields_data, &create_input( $tag, $subfield->{subfield}, '', $index_tag, $record, - $authorised_values_sth,$input + $authorised_values_sth,$input, $history ) ); } @@ -703,7 +713,7 @@ sub build_tabs { tagfirstsubfield => $subfields_data[0], fixedfield => $tag < 10?1:0, ); - + push @loop_data, \%tag_data ; } } @@ -806,6 +816,7 @@ $mandatory_z3950 = GetMandatoryFieldZ3950($frameworkcode); # -- Global my $record = -1; +my $history = undef; my $encoding = ""; my ( $biblionumbertagfield, @@ -816,7 +827,7 @@ my ( ); if ( $biblio && !$breedingid ) { - $record = $biblio->metadata->record; + ($record, $history) = GetMarcBiblioHistory($biblio->id); } if ($breedingid) { ( $record, $encoding ) = MARCfindbreeding( $breedingid ) ; @@ -902,7 +913,7 @@ if ( $op eq "addbiblio" ) { else { ( $biblionumber, $oldbibitemnum ) = AddBiblio( $record, $frameworkcode ); } - if ($redirect eq "items" || ($mode ne "popup" && !$is_a_modif && $redirect ne "view" && $redirect ne "just_save")){ + if ($redirect eq "items" || (defined $mode && $mode ne "popup" && !$is_a_modif && $redirect ne "view" && $redirect ne "just_save")){ if ($frameworkcode eq 'FA'){ print $input->redirect( '/cgi-bin/koha/cataloguing/additem.pl?' @@ -961,7 +972,7 @@ if ( $op eq "addbiblio" ) { } } else { # it may be a duplicate, warn the user and do nothing - build_tabs ($template, $record, $dbh,$encoding,$input); + build_tabs ($template, $record, $dbh,$encoding, $input, $history); $template->param( biblionumber => $biblionumber, biblioitemnumber => $biblioitemnumber, @@ -1009,7 +1020,8 @@ elsif ( $op eq "delete" ) { $record = $urecord; }; } - build_tabs( $template, $record, $dbh, $encoding,$input ); + + build_tabs( $template, $record, $dbh, $encoding, $input, $history ); $template->param( biblionumber => $biblionumber, biblionumbertagfield => $biblionumbertagfield, diff --git a/installer/data/mysql/atomicupdate/bug_14367_-_add_history_column.pl b/installer/data/mysql/atomicupdate/bug_14367_-_add_history_column.pl new file mode 100644 index 0000000000..2723ef78d7 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug_14367_-_add_history_column.pl @@ -0,0 +1,26 @@ +use Modern::Perl; + +return { + bug_number => "14367", + description => "History for MARC records", + up => sub { + my ($args) = @_; + my ($dbh, $out) = @$args{qw(dbh out)}; + + if( !column_exists( 'biblio_metadata', 'history' ) ) { + $dbh->do(q{ + ALTER TABLE biblio_metadata ADD COLUMN `history` longtext DEFAULT NULL AFTER `timestamp` + }); + + say $out "Added column 'biblio_metadata.history'"; + } + + if( !column_exists( 'deletedbiblio_metadata', 'history' ) ) { + $dbh->do(q{ + ALTER TABLE deletedbiblio_metadata ADD COLUMN `history` longtext DEFAULT NULL AFTER `timestamp` + }); + + say $out "Added column 'deletedbiblio_metadata.history'"; + } + }, +}; diff --git a/installer/data/mysql/kohastructure.sql b/installer/data/mysql/kohastructure.sql index 03d6cbebc9..45eff9eeee 100644 --- a/installer/data/mysql/kohastructure.sql +++ b/installer/data/mysql/kohastructure.sql @@ -1051,6 +1051,7 @@ CREATE TABLE `biblio_metadata` ( `schema` varchar(16) NOT NULL, `metadata` longtext NOT NULL, `timestamp` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `history` longtext DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `biblio_metadata_uniq_key` (`biblionumber`,`format`,`schema`), KEY `timestamp` (`timestamp`), @@ -2450,6 +2451,7 @@ CREATE TABLE `deletedbiblio_metadata` ( `schema` varchar(16) NOT NULL, `metadata` longtext NOT NULL, `timestamp` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + `history` longtext DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `deletedbiblio_metadata_uniq_key` (`biblionumber`,`format`,`schema`), KEY `timestamp` (`timestamp`), diff --git a/koha-tmpl/intranet-tmpl/prog/css/addbiblio.css b/koha-tmpl/intranet-tmpl/prog/css/addbiblio.css index 6932c84f68..65b122107a 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/addbiblio.css +++ b/koha-tmpl/intranet-tmpl/prog/css/addbiblio.css @@ -81,6 +81,11 @@ ul li.tag li.subfield_line.ui-sortable-helper::before { text-decoration: none; } +.buttonHistory { + font-weight : bold; + text-decoration : none; +} + a.expandfield { text-decoration: none; } @@ -287,6 +292,9 @@ fieldset.order_details ol li .label:first-child { margin: 0 .5em; } +.readonly { border-width : 1px; border-style: inset; padding-left : 15px; background: #EEE url(../img/locked.png) center left no-repeat; width:29em; } +.subfield_history { margin : 0 .5em; float: right; } + #cataloguing_additem_itemlist { margin-bottom: 1em; } @@ -514,3 +522,17 @@ tbody tr.active td { font-weight: bold; padding: 0; } + +.history_container { + display: none; +} + +.history_table { + width: 100%; + display: none; + float: right; + clear: both; + font-size: 75%; + width: 75%; + margin: 0.3em; +} diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt index 35147c3dbc..2bf4c5497d 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt @@ -1,6 +1,7 @@ [% USE raw %] [% USE Asset %] [% USE Koha %] +[% USE KohaDates %] [% INCLUDE 'doc-head-open.inc' %] [% IF ( biblionumber ) %]Editing [% title | html %] (Record number [% biblionumber | html %])[% ELSE %]Add MARC record[% END %] › Cataloging › Koha [% INCLUDE 'doc-head-close.inc' %] @@ -27,6 +28,31 @@ var Sticky; $(document).ready(function() { + $(".input_marceditor").click(function(){ + var tag = $(this).attr('id'); + historyToggle(tag + '_history'); + return false; + }); + + $(".tag_editor").click(function(){ + var tag = $(this).attr('id'); + openAuth(this.parentNode.parentNode.getElementsByTagName('input')[1].id, tag, 'biblio'); + return false; + }); + + $(".buttonHistory").click(function(){ + var tag = $(this).attr('id'); + HistoryToggle(tag + '_history'); + return false; + }); + + $(".hist_rollback").click(function(){ + var tag = $(this).attr('data-tag'); + var value = $(this).attr('data-history'); + HistoryRollback(tag, value); + return false; + }); + [% IF bib_doesnt_exist %] $("#addbibliotabs").hide(); $("#toolbar").hide(); @@ -1171,16 +1197,16 @@ function PopupMARCFieldDoc(field) {
[% IF ( mv.type == 'text' ) %] [% IF ( mv.authtype ) %] - Tag editor + Tag editor [% END %] [% ELSIF ( mv.type == 'text_complex' ) %] [% IF mv.noclick %] [% ELSE %] [% IF mv.plugin == "upload.pl" %] - Upload + Upload [% ELSE %] - Tag editor + Tag editor [% END %] [% END %] @@ -1194,6 +1220,28 @@ function PopupMARCFieldDoc(field) { [% END %]
+ [% IF ( subfield_loo.history ) %] + + + History + + + + + + + + + + [% FOREACH hist IN subfield_loo.history %] + + + + + + [% END %] +
ValueDateUser
[% hist.data | html %][% hist.datetime | $KohaDates with_hours = 1 %][% hist.title | html %] [% hist.firstname | html %] [% hist.surname | html %]
+ [% END %] [% END # /FOREACH subfield_loop %] diff --git a/koha-tmpl/intranet-tmpl/prog/img/icon-history.png b/koha-tmpl/intranet-tmpl/prog/img/icon-history.png new file mode 100644 index 0000000000000000000000000000000000000000..c65d0c6750c915ec1fc7c47eae52a329d0358404 GIT binary patch literal 513 zcmV+c0{;DpP)JY z0A>feN^JlD010qNS#tmYF*N`HF*N}XOdPos*6keP$;J=l&he8UQs zGxKMSYlg`Yu^&s=fz6fv8opvSGanA{%@DbV+ZAaApGy_Hs`G1^c_||Ps^ECZ++Y>Q z@CGwjz&6~+fkL^FnHM{i>a9Y4fw9bdiA}hJEt&Z#GY=LqxDXM$J0<%Z#>+*o_#MO# z{I0B?!3T74wp&2w%Kj`f-}y(BKEXYl$4n<8#>;cTqlnm8aem=_T~T?ySLJuAgA var lis = original.getElementsByTagName('li'); diff --git a/t/db_dependent/BiblioHistory.t b/t/db_dependent/BiblioHistory.t new file mode 100644 index 0000000000..18be6c7e99 --- /dev/null +++ b/t/db_dependent/BiblioHistory.t @@ -0,0 +1,249 @@ +#!/usr/bin/perl + +use Modern::Perl; +use t::lib::TestBuilder; +use t::lib::Mocks; +use Test::More tests => 6; +use POSIX qw(strftime); + +use MARC::Record; +use MARC::Field; +use JSON qw(encode_json); + +use Koha::MetadataRecord::History; + +BEGIN { + use_ok('C4::Biblio'); +} + +our $dbh = C4::Context->dbh; +$dbh->{AutoCommit} = 0; +$dbh->{RaiseError} = 0; + +$dbh->do(q|DELETE FROM issues|); +$dbh->do(q|DELETE FROM items|); +$dbh->do(q|DELETE FROM borrowers|); +$dbh->do(q|DELETE FROM branches|); +$dbh->do(q|DELETE FROM categories|); +$dbh->do(q|DELETE FROM biblioitems|); + +my $builder = t::lib::TestBuilder->new(); + +my $branch = $builder->build( + { + source => 'Branch', + } +); + +my $category = $builder->build( + { + source => 'Category', + } +); + +my $patron1 = $builder->build( + { + source => 'Borrower', + value => { + title => 'Mr/Mrs', + firstname => 'First name', + surname => 'Surname', + categorycode => $category->{categorycode}, + branchcode => $branch->{branchcode}, + }, + } +); + +my $patron2 = $builder->build( + { + source => 'Borrower', + value => { + title => 'Mr/Mrs 2', + firstname => 'First name 2', + surname => 'Surname 2', + categorycode => $category->{categorycode}, + branchcode => $branch->{branchcode}, + }, + } +); + +my $biblio = $builder->build_sample_biblio; + +C4::Context->_new_userenv('DUMMY_SESSION_ID'); +C4::Context->set_userenv( $patron1->{borrowernumber}, + $patron1->{userid}, 'usercnum', $patron1->{firstname}, $patron1->{surname}, + $branch->{branchcode}, 'My library', 0 ); + +# Item with no pre-existing history backlog. +my $record1 = MARC::Record->new(); +my $field1 = MARC::Field->new( + 245, '1', '0', + 'a' => 'First Title', + 'c' => 'Some dude' +); +$record1->append_fields($field1); + +my $record2 = MARC::Record->new(); +my $field2 = MARC::Field->new( + 245, '1', '0', + 'a' => 'Second Title', + 'c' => 'Former dude' +); +$record2->append_fields($field2); + +my $biblio_metadata1 = $builder->build( + { + source => 'BiblioMetadata', + value => { + metadata => $record1->as_xml(), + history => undef, + } + } +); + +# items{tagname}{subfield}{timestamp}{borrowernumber|timestamp|marcxml} +my $items = [ + { + borrowernumber => $patron1->{borrowernumber}, + datetime => '2012-08-12 12:00:10', + marcxml => $record1->as_xml(), + }, + { + borrowernumber => $patron2->{borrowernumber}, + datetime => '2012-08-12 12:00:10', + marcxml => $record2->as_xml(), + } +]; + +# Item with previous history entries. +my $biblio_metadata2 = $builder->build( + { + source => 'BiblioMetadata', + value => { + metadata => $record2->as_xml(), + history => encode_json($items), + } + } +); + +my $expected_history = { + 245 => { + a => [ + { + borrowernumber => $patron1->{borrowernumber}, + data => 'First Title', + datetime => '2012-08-12 12:00:10' + }, + { + borrowernumber => $patron2->{borrowernumber}, + data => 'Second Title', + datetime => '2012-08-12 12:00:10' + }, + ], + c => [ + { + borrowernumber => $patron1->{borrowernumber}, + data => 'Some dude', + datetime => '2012-08-12 12:00:10' + }, + { + borrowernumber => $patron2->{borrowernumber}, + data => 'Former dude', + datetime => '2012-08-12 12:00:10' + }, + ], + }, +}; + +my $expected_users = { + $patron1->{borrowernumber} => undef, + $patron2->{borrowernumber} => undef, +}; + +# Tests the decoding and reindexing of the data, without any borrower +# information implanted into the history itself. + +my ( $history, $users ) = C4::Biblio::HistoryParse( encode_json($items) ); + +is_deeply( $history, $expected_history, 'HistoryParse: Output' ); +is_deeply( $users, $expected_users, 'HistoryParse: Users' ); + +# Tests the extraction of full borrower details based on the borrowernumber. +my $expected_details = { + $patron1->{borrowernumber} => { + firstname => $patron1->{firstname}, + surname => $patron1->{surname}, + title => $patron1->{title}, + }, + $patron2->{borrowernumber} => { + firstname => $patron2->{firstname}, + surname => $patron2->{surname}, + title => $patron2->{title}, + } +}; + +my $details = HistoryBorrowerDetails($expected_users); +is_deeply( $details, $expected_details, 'HistoryBorrowerDetails: Output' ); + +{ + my $expected_mapped_history = { + 245 => { + a => [ + { + borrowernumber => $patron1->{borrowernumber}, + data => 'First Title', + firstname => $patron1->{firstname}, + surname => $patron1->{surname}, + title => $patron1->{title}, + datetime => '2012-08-12 12:00:10' + }, + { + borrowernumber => $patron2->{borrowernumber}, + data => 'Second Title', + firstname => $patron2->{firstname}, + surname => $patron2->{surname}, + title => $patron2->{title}, + datetime => '2012-08-12 12:00:10' + }, + ], + c => [ + { + borrowernumber => $patron1->{borrowernumber}, + data => 'Some dude', + firstname => $patron1->{firstname}, + surname => $patron1->{surname}, + title => $patron1->{title}, + datetime => '2012-08-12 12:00:10' + }, + { + borrowernumber => $patron2->{borrowernumber}, + data => 'Former dude', + firstname => $patron2->{firstname}, + surname => $patron2->{surname}, + title => $patron2->{title}, + datetime => '2012-08-12 12:00:10' + }, + ] + } + }; + + $history = C4::Biblio::HistoryMapUsers( $expected_history, $details ); + + is_deeply( $history, $expected_mapped_history, 'HistoryMapUsers: Output' ); + + #my $record; + #( $record, $history ) = GetMarcBiblioHistory( $biblioitem2->{biblionumber} ); + + #is_deeply( $record, $record2 ); + #is_deeply( $history, $expected_mapped_history ); +} + +# The C4::Context will provide the expected user borrowernumber. +my $record = HistoryRecordNew($record1); + +my $expected_record = { + borrowernumber => C4::Context->userenv()->{number}, + marcxml => $record1->as_xml_record(), + datetime => POSIX::strftime( '%Y-%m-%d %T', localtime ), +}; +is_deeply( $record, $expected_record ); -- 2.20.1