From 0b04b95e91b6da18bdcb6a2b18346eab277da0b4 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 | 29 ++- Koha/MetadataRecord/History.pm | 261 +++++++++++++++++++++ cataloguing/addbiblio.pl | 49 ++-- .../bug_14367_-_add_history_column.perl | 9 + koha-tmpl/intranet-tmpl/prog/css/addbiblio.css | 23 ++ .../prog/en/modules/cataloguing/addbiblio.tt | 55 ++++- koha-tmpl/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 | 256 ++++++++++++++++++++ 9 files changed, 664 insertions(+), 28 deletions(-) create mode 100644 Koha/MetadataRecord/History.pm create mode 100644 installer/data/mysql/atomicupdate/bug_14367_-_add_history_column.perl 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 3e03c52501..b875f4e294 100644 --- a/C4/Biblio.pm +++ b/C4/Biblio.pm @@ -87,6 +87,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 @@ -114,6 +115,7 @@ use Koha::SearchEngine; use Koha::SearchEngine::Indexer; use Koha::Libraries; use Koha::Util::MARC; +use Koha::MetadataRecord::History; =head1 NAME @@ -289,8 +291,8 @@ sub AddBiblio { BiblioAutoLink( $record, $frameworkcode ); } - # now add the record - ModBiblioMarc( $record, $biblionumber ) unless $defer_marc_save; + # now add the record (history parameter is undef: no history yet) + ModBiblioMarc( $record, $biblionumber, undef ) unless $defer_marc_save; # update OAI-PMH sets if(C4::Context->preference("OAI-PMH:AutoUpdateSets")) { @@ -370,9 +372,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 ); @@ -383,7 +385,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 ); + &ModBiblioMarc( $record, $biblionumber, $history ); # modify the other koha tables _koha_modify_biblio( $dbh, $oldbiblio, $frameworkcode ); @@ -2932,7 +2934,7 @@ Function exported, but should NOT be used, unless you really know what you're do sub ModBiblioMarc { # pass the MARC::Record to this function, and it will create the records in # the marcxml field - my ( $record, $biblionumber ) = @_; + my ( $record, $biblionumber, $history ) = @_; if ( !$record ) { carp 'ModBiblioMarc passed an undefined record'; return; @@ -3000,6 +3002,21 @@ sub ModBiblioMarc { my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); $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 f1a13bed7b..f55c0c3283 100755 --- a/cataloguing/addbiblio.pl +++ b/cataloguing/addbiblio.pl @@ -46,12 +46,10 @@ use C4::ImportBatch qw( GetImportRecordMarc ); use C4::Charset qw( SetMarcUnicodeFlag ); use Koha::BiblioFrameworks; use Koha::DateUtils qw( dt_from_string ); - +use C4::Koha; use Koha::ItemTypes; +use Koha::MetadataRecord::History; use Koha::Libraries; - -use Koha::BiblioFrameworks; - use MARC::File::USMARC; use MARC::File::XML; use URI::Escape qw( uri_escape_utf8 ); @@ -283,10 +281,15 @@ sub GetMandatoryFieldZ3950 { =cut sub create_input { - my ( $tag, $subfield, $value, $index_tag, $tabloop, $rec, $authorised_values_sth,$cgi ) = @_; - + + my ( $tag, $subfield, $value, $index_tag, $tabloop, $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; + # if there is no value provided but a default value in parameters, get it if ( $value eq '' ) { $value = $tagslib->{$tag}->{$subfield}->{defaultvalue} // q{}; @@ -304,7 +307,7 @@ sub create_input { # And <> with surname (?) my $username=(C4::Context->userenv?C4::Context->userenv->{'surname'}:"superlibrarian"); $value=~s/<>/$username/g; - + } my $dbh = C4::Context->dbh; @@ -327,6 +330,7 @@ sub create_input { value => $value, maxlength => $tagslib->{$tag}->{$subfield}->{maxlength}, random => CreateKey(), + history => $taghistory, ); if(exists $mandatory_z3950->{$tag.$subfield}){ @@ -385,6 +389,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 @@ -407,6 +412,7 @@ sub create_input { javascript => $plugin->javascript, plugin => $plugin->name, noclick => $plugin->noclick, + history => $taghistory, }; } else { warn $plugin->errstr; @@ -419,6 +425,7 @@ sub create_input { size => 67, maxlength => $subfield_data{maxlength}, readonly => 0, + history => $taghistory, }; } @@ -431,6 +438,7 @@ sub create_input { value => $value, size => 67, maxlength => $subfield_data{maxlength}, + history => $taghistory, }; } @@ -451,6 +459,7 @@ sub create_input { id => $subfield_data{id}, name => $subfield_data{id}, value => $value, + history => $taghistory, }; } @@ -463,6 +472,7 @@ sub create_input { size => 67, maxlength => $subfield_data{maxlength}, readonly => 0, + history => $taghistory, }; } @@ -488,11 +498,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 @@ -548,7 +558,6 @@ sub build_tabs { } # loop through each field foreach my $field (@fields) { - my @subfields_data; if ( $tag < 10 ) { my ( $value, $subfield ); @@ -568,7 +577,7 @@ sub build_tabs { @subfields_data, &create_input( $tag, $subfield, $value, $index_tag, $tabloop, $record, - $authorised_values_sth,$input + $authorised_values_sth,$input, $history ) ); } @@ -583,7 +592,7 @@ sub build_tabs { @subfields_data, &create_input( $tag, $subfield, $value, $index_tag, $tabloop, - $record, $authorised_values_sth,$input + $record, $authorised_values_sth,$input, $history ) ); } @@ -613,7 +622,7 @@ sub build_tabs { @subfields_data, &create_input( $tag, $subfield, '', $index_tag, $tabloop, $record, - $authorised_values_sth,$input + $authorised_values_sth,$input, $history ) ); } @@ -669,7 +678,7 @@ sub build_tabs { @subfields_data, &create_input( $tag, $subfield->{subfield}, '', $index_tag, $tabloop, $record, - $authorised_values_sth,$input + $authorised_values_sth,$input, $history ) ); } @@ -687,7 +696,7 @@ sub build_tabs { tagfirstsubfield => $subfields_data[0], fixedfield => $tag < 10?1:0, ); - + push @loop_data, \%tag_data ; } } @@ -789,6 +798,7 @@ $mandatory_z3950 = GetMandatoryFieldZ3950($frameworkcode); # -- Global my $record = -1; +my $history = undef; my $encoding = ""; my ( $biblionumbertagfield, @@ -799,7 +809,7 @@ my ( ); if (($biblionumber) && !($breedingid)){ - $record = GetMarcBiblio({ biblionumber => $biblionumber }); + ($record, $history) = GetMarcBiblioHistory($biblionumber); } if ($breedingid) { ( $record, $encoding ) = MARCfindbreeding( $breedingid ) ; @@ -869,7 +879,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?' @@ -928,7 +938,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, @@ -975,7 +985,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.perl b/installer/data/mysql/atomicupdate/bug_14367_-_add_history_column.perl new file mode 100644 index 0000000000..b8a1af42fb --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug_14367_-_add_history_column.perl @@ -0,0 +1,9 @@ +$DBversion = 'XXX'; +if( CheckVersion( $DBversion ) ) { + unless( column_exists( 'biblio_metadata', 'history' ) ) { + $dbh->do(q|ALTER TABLE biblio_metadata ADD history longtext default NULL AFTER metadata|); + } + + SetVersion( $DBversion ); + print "Upgrade to $DBversion done (Bug 14367: Add column biblio_metadata.history)\n"; +} diff --git a/koha-tmpl/intranet-tmpl/prog/css/addbiblio.css b/koha-tmpl/intranet-tmpl/prog/css/addbiblio.css index 3c6974fb5c..6a0db6d0ba 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/addbiblio.css +++ b/koha-tmpl/intranet-tmpl/prog/css/addbiblio.css @@ -80,6 +80,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; } @@ -232,6 +237,9 @@ a.tagnum { .linktools a:hover { background-color: #FFC; } .subfield_controls { 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; } @@ -252,6 +260,7 @@ tbody tr.active td { width: 100%; z-index: 1000; } + #loading div { background : transparent url(../img/loading.gif) top left no-repeat; font-size : 175%; @@ -442,3 +451,17 @@ tbody tr.active td { border-bottom: 1px solid #b9d8d9; border-radius: 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 2d158edac2..8680f927d2 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' %] @@ -26,6 +27,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(); @@ -1096,16 +1122,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 %] @@ -1119,6 +1145,29 @@ 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..c1a2c48448 --- /dev/null +++ b/t/db_dependent/BiblioHistory.t @@ -0,0 +1,256 @@ +#!/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( + { + source => 'Biblio', + value => { + branchcode => $branch->{branchcode}, + }, + } +); + +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 $biblioitem1 = $builder->build( + { + source => 'Biblioitem', + value => { + marcxml => $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 $biblioitem2 = $builder->build( + { + source => 'Biblioitem', + value => { + marcxml => $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.11.0