From 505da2c0abad14bb2258202449fa7e8d2a75a337 Mon Sep 17 00:00:00 2001 From: David Gustafsson Date: Thu, 2 Feb 2017 13:36:38 +0100 Subject: [PATCH] Bug 14957 - Rule based permission for MARC fields Add a rule based permission system for MARC field modifications. Test plan: 1. Apply this patch 2. Run updatedatabase.pl 3. Log in to staff client 4. Enable new syspref MARCPermissions 5. You should see a new link "MARC field permissions" in the "Catalog" section under "Koha administrtion". 6. Go to the "MARC field permissions" page 7. Next to the page heading ("Manage MARC field permissions") there is a button with a question mark, click this for more information about the rule system. 8. Add desired rules to the rule table, press the +-button to add. 9. Clicking the pen-icon (edit) should allow you to edit corresponding rule. 10. Clicking the x-icon should remove corresponding rule after confirmation. 11. Selecting one or more rules followed by clicking the trash-icon should remove all selected rules after confirmation. 12. To test your rules, import or in other way modify a record. 13. Check that the record was modified as defined by your rules. Sponsored-by: Halland County Library --- C4/Biblio.pm | 980 ++++++++++++++++++++- C4/ImportBatch.pm | 2 +- C4/Installer/PerlDependencies.pm | 5 + C4/Items.pm | 2 + admin/marc-permissions.pl | 123 +++ cataloguing/addbiblio.pl | 13 +- help.pl | 5 + .../bug_14957-marc-permissions-syspref.sql | 3 + .../atomicupdate/bug_14957-marc-permissions.sql | 31 + .../intranet-tmpl/prog/en/includes/admin-menu.inc | 1 + .../prog/en/modules/admin/admin-home.tt | 2 + .../prog/en/modules/admin/marc-permissions.tt | 314 +++++++ .../en/modules/admin/preferences/cataloguing.pref | 30 +- .../prog/en/modules/admin/preferences/logs.pref | 6 + .../prog/en/modules/cataloguing/addbiblio.tt | 1 + .../prog/en/modules/help/marc-permissions.tt | 148 ++++ misc/migration_tools/bulkmarcimport.pl | 3 +- misc/migration_tools/import_lexile.pl | 3 +- t/db_dependent/Biblio/MARCPermissions.t | 280 ++++++ tools/batch_record_modification.pl | 13 +- 20 files changed, 1924 insertions(+), 41 deletions(-) mode change 100644 => 100755 C4/Biblio.pm create mode 100755 admin/marc-permissions.pl create mode 100644 installer/data/mysql/atomicupdate/bug_14957-marc-permissions-syspref.sql create mode 100644 installer/data/mysql/atomicupdate/bug_14957-marc-permissions.sql create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-permissions.tt create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/help/marc-permissions.tt create mode 100755 t/db_dependent/Biblio/MARCPermissions.t diff --git a/C4/Biblio.pm b/C4/Biblio.pm old mode 100644 new mode 100755 index d52d7807af..54724e263a --- a/C4/Biblio.pm +++ b/C4/Biblio.pm @@ -28,6 +28,7 @@ use MARC::File::USMARC; use MARC::File::XML; use POSIX qw(strftime); use Module::Load::Conditional qw(can_load); +use String::Similarity; use C4::Koha; use C4::Log; # logaction @@ -104,6 +105,12 @@ BEGIN { &CountItemsIssued &CountBiblioInOrders &GetSubscriptionsId + + &GetMarcPermissionsRules + &GetMarcPermissionsModules + &ModMarcPermissionsRule + &AddMarcPermissionsRule + &DelMarcPermissionsRule ); # To modify something @@ -131,6 +138,7 @@ BEGIN { # they are useful in a few circumstances, so they are exported, # but don't use them unless you are a core developer ;-) push @EXPORT, qw( + &ApplyMarcPermissions &ModBiblioMarc ); @@ -268,7 +276,7 @@ sub AddBiblio { =head2 ModBiblio - ModBiblio( $record,$biblionumber,$frameworkcode); + ModBiblio($record, $biblionumber, $frameworkcode, $options); Replace an existing bib record identified by C<$biblionumber> with one supplied by the MARC::Record object C<$record>. The embedded @@ -289,7 +297,9 @@ Returns 1 on success 0 on failure =cut sub ModBiblio { - my ( $record, $biblionumber, $frameworkcode ) = @_; + my ( $record, $biblionumber, $frameworkcode, $options ) = @_; + $options //= {}; + if (!$record) { carp 'No record passed to ModBiblio'; return 0; @@ -327,13 +337,13 @@ sub ModBiblio { _koha_marc_update_bib_ids( $record, $frameworkcode, $biblionumber, $biblioitemnumber ); # load the koha-table data object - my $oldbiblio = TransformMarcToKoha( $record, $frameworkcode ); + my $oldbiblio = TransformMarcToKoha( $record, $frameworkcode, undef, $biblionumber, { 'context' => $options->{'context'} } ); # update MARC subfield that stores biblioitems.cn_sort _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, $frameworkcode ); + ModBiblioMarc( $record, $biblionumber, $frameworkcode, { 'context' => $options->{'context'} } ); # modify the other koha tables _koha_modify_biblio( $dbh, $oldbiblio, $frameworkcode ); @@ -2597,10 +2607,10 @@ sub TransformHtmlToMarc { =head2 TransformMarcToKoha - $result = TransformMarcToKoha( $record, $frameworkcode ) + $result = TransformMarcToKoha($record, $frameworkcode, $biblionumber, $options) Extract data from a MARC bib record into a hashref representing -Koha biblio, biblioitems, and items fields. +Koha biblio, biblioitems, and items fields. If passed an undefined record will log the error and return an empty hash_ref @@ -2608,7 +2618,7 @@ hash_ref =cut sub TransformMarcToKoha { - my ( $record, $frameworkcode, $limit_table ) = @_; + my ( $record, $frameworkcode, $limit_table, $biblionumber, $options ) = @_; my $result = {}; if (!defined $record) { @@ -2629,6 +2639,17 @@ sub TransformMarcToKoha { $tables{'biblioitems'} = 1; } + # apply permissions + if ( C4::Context->preference('MARCPermissions') && $biblionumber && defined $options && exists $options->{'context'} ) { + $record = ApplyMarcPermissions({ + biblionumber => $biblionumber, + record => $record, + frameworkcode => $frameworkcode, + filter => $options->{'context'}, + nolog => 1 + }); + } + # traverse through record MARCFIELD: foreach my $field ( $record->fields() ) { my $tag = $field->tag(); @@ -3412,7 +3433,7 @@ sub _koha_delete_biblio_metadata { =head2 ModBiblioMarc - &ModBiblioMarc($newrec,$biblionumber,$frameworkcode); + &ModBiblioMarc($newrec, $biblionumber, $frameworkcode, $options); Add MARC XML data for a biblio to koha @@ -3423,7 +3444,8 @@ 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, $frameworkcode ) = @_; + my ( $record, $biblionumber, $frameworkcode, $options ) = @_; + if ( !$record ) { carp 'ModBiblioMarc passed an undefined record'; return; @@ -3436,6 +3458,23 @@ sub ModBiblioMarc { if ( !$frameworkcode ) { $frameworkcode = ""; } + + # apply permissions + if ( + C4::Context->preference('MARCPermissions') && + defined $options && + exists $options->{'context'} + ) { + $record = ApplyMarcPermissions( + { + biblionumber => $biblionumber, + record => $record, + frameworkcode => $frameworkcode, + filter => $options->{'context'} + } + ); + } + my $sth = $dbh->prepare("UPDATE biblio SET frameworkcode=? WHERE biblionumber=?"); $sth->execute( $frameworkcode, $biblionumber ); $sth->finish; @@ -3491,7 +3530,7 @@ sub ModBiblioMarc { $count = &CountBiblioInOrders( $biblionumber); -This function return count of biblios in orders with $biblionumber +This function return count of biblios in orders with $biblionumber =cut @@ -3499,7 +3538,7 @@ sub CountBiblioInOrders { my ($biblionumber) = @_; my $dbh = C4::Context->dbh; my $query = "SELECT count(*) - FROM aqorders + FROM aqorders WHERE biblionumber=? AND (datecancellationprinted IS NULL OR datecancellationprinted='0000-00-00')"; my $sth = $dbh->prepare($query); $sth->execute($biblionumber); @@ -3756,6 +3795,925 @@ sub RemoveAllNsb { return $record; } +=head2 ApplyMarcPermissions + + my $record = ApplyMarcPermissions($arguments) + +Applies marc permission rules to a record. + +C<$arguments> is expected to be a hashref with below keys defined. + +=over 4 + +=item C +biblionumber of old record + +=item C +record that will modify old record + +=item C +only tags included in framework will be processed + +=item C +hashref containing at least one filter module from the marc_permissions_modules +table in form {module => filter}. Three predefined filter modules exists: + + * source + * category + * borrower + +=item C +optional reference to array that will be filled with rule evaluation log +entries. + +=item C +optional boolean which when true disables logging to action log. + +=back + +Returns: + +=over 4 + +=item C<$record> + +new MARC record based on C with C applied. If no old +record for C can be found, C is returned unchanged. +Default action when no matching filter found is to leave old record unchanged. + +=back + +=cut + +sub ApplyMarcPermissions { + my $arguments = shift; + my $biblionumber = $arguments->{biblionumber}; + my $new_record = $arguments->{record}; + my $frameworkcode = $arguments->{frameworkcode} || ''; + + if ( !$biblionumber ) { + carp 'ApplyMarcPermissions called on undefined biblionumber'; + return; + } + if ( !$new_record ) { + carp 'ApplyMarcPermissions called on undefined record'; + return; + } + my $filter = $arguments->{filter} + or return $new_record; + + my $log = $arguments->{log} || []; + my $nolog = $arguments->{nolog}; + + my $dbh = C4::Context->dbh; + + my $is_regex = sub { + my ( $tag, $m ) = @_; + # tag is not exactly same as possible regex + $tag ne $m && + + # wildcard + $m ne '*' && + + # valid tagDataType + $m !~ /^(0[1-9A-z][\dA-Z]) | + ([1-9A-z][\dA-z]{2})$/x && + + # nor valid controltagDataType + $m !~ /00[1-9A-Za-z]{1}/ && + + # so we try it as a regex + $tag =~ /^$m$/ + }; + + my $old_record = GetMarcBiblio($biblionumber); + if ( $old_record ) { + my $ret_record = MARC::Record->new(); + my $perm = GetMarcPermissions() or return $new_record; + my $marc_struct = GetMarcStructure(1, $frameworkcode); + my $similarity_pref = C4::Context->preference("MARCPermissionsCorrectionSimilarity"); + $similarity_pref = defined $similarity_pref && + $similarity_pref >= 0 && + $similarity_pref <= 100 ? + $similarity_pref/100.0 : undef; + + # First came the leader ... + $ret_record->leader($old_record->leader()); + + # ... and then came all the tags in the framework + for my $tag (sort keys %{$marc_struct}) { + my @old_fields = $old_record->field($tag); + my @new_fields = $new_record->field($tag); + next unless @old_fields or @new_fields; + my @perms = ( + $perm->{'*'}, + ( # regex tags + map { $perm->{$_} } + grep { $is_regex->( $tag, $_ ) } sort keys %{$perm} + ), + $perm->{$tag} + ); + + # There can be more than one field for each tag, so we have to + # iterate all fields and distinguish which are new, removed or + # modified + my $max_fields = ($#old_fields, $#new_fields) + [$#old_fields< $#new_fields]; + for ( my $i_field = 0; $i_field <= $max_fields; $i_field++ ) { + my $old_field = $old_fields[$i_field]; + my $new_field = $new_fields[$i_field]; + + # Existing field + if ( $old_field and $new_field ) { + # Control fields + if ( $old_field->is_control_field() ) { + # Existing control field + if ( $old_field and $new_field ) { + my $on_existing = GetMarcPermissionsAction('on_existing', $filter, @perms); + if ( defined $on_existing->{action} ) { + push @{$log}, + { + rule => $on_existing->{rule}, + event => "existing", + action => $on_existing->{action}, + tag => $tag, + subfieldcode => undef, + filter => $filter + }; + if ( $on_existing->{action} eq 'skip' ) { + $ret_record->insert_fields_ordered($old_field); + next; + } elsif ( $on_existing->{action} eq 'overwrite' ) { + $ret_record->insert_fields_ordered($new_field); + next; + } elsif ( $on_existing->{action} eq 'add' ) { + $ret_record->insert_fields_ordered($old_field); + $ret_record->insert_fields_ordered($new_field); + next; + } elsif ( $on_existing->{action} eq 'add_or_correct' ) { + if ( $similarity_pref ) { + my $similarity = similarity $old_field->data(), $new_field->data(); + if ( $similarity >= $similarity_pref ) { + $ret_record->insert_fields_ordered($new_field); + } else { + $ret_record->insert_fields_ordered($old_field); + $ret_record->insert_fields_ordered($new_field); + } + } else { + $ret_record->insert_fields_ordered($old_field); + } + } + } else { # default to skip + $ret_record->insert_fields_ordered($old_field); + next; + } + # New control field + } elsif ( not $old_field and $new_field ) { + my $on_new = GetMarcPermissionsAction('on_new', $filter, @perms); + if ( defined $on_new->{action} ) { + push @{$log}, + { + rule => $on_new->{rule}, + event => "new", + action => $on_new->{action}, + tag => $tag, + subfieldcode => undef, + filter => $filter + }; + if ( $on_new->{action} eq 'skip' ) { # Redundant + next; + } elsif ( $on_new->{action} eq 'add' ) { + $ret_record->insert_fields_ordered($new_field); + next; + } + } + # Removed control field + } elsif ( $old_field and not $new_field ) { + my $on_removed = GetMarcPermissionsAction('on_removed', $filter, @perms); + if ( defined $on_removed->{action} ) { + push @{$log}, + { + rule => $on_removed->{rule}, + event => "removed", + action => $on_removed->{action}, + tag => $tag, + subfieldcode => undef + }; + if ( $on_removed->{action} eq 'skip' ) { + $ret_record->insert_fields_ordered($old_field); + next; + } elsif ( $on_removed->{action} eq 'remove' ) { + next; + } + } else { # default to skip + $ret_record->insert_fields_ordered($old_field); + next; + } + } + # Indicators and subfields + } else { + # Indicators + my @old_ind = map { $old_field->indicator($_) } (1,2); + my @new_ind = map { $new_field->indicator($_) } (1,2); + my @ind = ('',''); + + for my $i ((0,1)) { + my @ind_perms = ( + @perms, + $perm->{$tag}->{subfields}->{ 'i' . ( $i + 1 ) } + ); + + # Existing indicator + if ( defined $old_ind[$i] and defined $new_ind[$i] ) { + my $on_existing = GetMarcPermissionsAction('on_existing', $filter, @ind_perms); + if ( defined $on_existing->{action} ) { + push @{$log}, + { + rule => $on_existing->{rule}, + event => "existing", + action => $on_existing->{action}, + tag => $tag, + subfieldcode => 'i' . ( $i + 1 ), + filter => $filter + }; + if ( $on_existing->{action} eq 'skip' ) { + $ind[$i] = $old_ind[$i]; + } elsif ( $on_existing->{action} eq 'overwrite' ) { + $ind[$i] = $new_ind[$i]; + } + } else { # default to skip + $ind[$i] = $old_ind[$i]; + } + # New indicator + } elsif ( not defined $old_ind[$i] and defined $new_ind[$i] ) { + my $on_new = GetMarcPermissionsAction('on_new', $filter, @ind_perms); + if ( defined $on_new->{action} ) { + push @{$log}, + { + rule => $on_new->{rule}, + event => "new", + action => $on_new->{action}, + tag => $tag, + subfieldcode => 'i' . ( $i + 1 ), + filter => $filter + }; + if ( $on_new->{action} eq 'skip' ) { + $ind[$i] = $old_ind[$i]; + } elsif ( $on_new->{action} eq 'add' ) { + $ind[$i] = $new_ind[$i]; + } + } + # Removed indicator + } elsif ( defined $old_ind[$i] and not defined $new_ind[$i] ) { + my $on_removed = GetMarcPermissionsAction('on_removed', $filter, @ind_perms); + if ( defined $on_removed->{action} ) { + push @{$log}, + { + rule => $on_removed->{rule}, + event => "removed", + action => $on_removed->{action}, + tag => $tag, + subfieldcode => 'i' . ( $i + 1 ), + filter => $filter + }; + if ( $on_removed->{action} eq 'skip' ) { + $ind[$i] = $old_ind[$i]; + } elsif ( $on_removed->{action} eq 'remove' ) { + $ind[$i] = ''; + } + } else { # default to skip + $ind[$i] = $old_ind[$i]; + } + } + } + + # Subfields + my @subfields = (); + my %proccessed_subcodes = (); + + # Try to preserve subfield order by first processing + # subfields from the old record, and then subfields from + # the framework structure unless include in old record. + my @sorted_subfields = ( + map { shift @{$_} } $old_field->subfields(), + () = sort keys %{ $marc_struct->{$tag} } + ); + for my $subcode ( @sorted_subfields ) { + next if $proccessed_subcodes{$subcode}; + $proccessed_subcodes{$subcode} = 1; + next unless ref $marc_struct->{$tag}{$subcode} eq 'HASH'; + my @old_subfields = $old_field->subfield($subcode); + my @new_subfields = $new_field->subfield($subcode); + next unless @old_subfields or @new_subfields; + + my @subfield_perms = ( + @perms, + $perm->{'*'}->{subfields}->{'*'}, + $perm->{$tag}->{subfields}->{'*'}, + ( # tag->regex + map { $perm->{$tag}->{subfields}->{$_} } + grep { + # subcode is not exactly same as + # possible regex + $subcode ne $_ && + + # wildcard, not checking for valid + # subfieldcodeDataType since it + # contains too many regex characters + $_ !~ /^(\*)$/ && + + # so we try it as a regex + $subcode =~ /$_/ + } sort + keys %{ $perm->{$tag}->{subfields} } + ), + ( # regex->* + map { $perm->{$_}->{subfields}->{'*'} } + grep { $is_regex->( $tag, $_ ) } + sort keys %{$perm} + ), + $perm->{'*'}->{subfields}->{$subcode}, + ( # regex->subcode + map { $perm->{$_}->{subfields}->{$subcode} } + grep { $is_regex->( $tag, $_ ) } + sort keys %{$perm} + ), + $perm->{$tag}->{subfields}->{$subcode} + ); + + # Existing subfield + if ( @old_subfields and @new_subfields ) { + my $on_existing = GetMarcPermissionsAction('on_existing', $filter, @subfield_perms); + if ( defined $on_existing->{action} ) { + push @{$log}, + { + rule => $on_existing->{rule}, + event => "existing", + action => $on_existing->{action}, + tag => $tag, + subfieldcode => $subcode, + filter => $filter + }; + if ( $on_existing->{action} eq 'skip' ) { + push(@subfields, map {($subcode,$_)} @old_subfields); + next; + } elsif ( $on_existing->{action} eq 'overwrite' ) { + push(@subfields, map {($subcode,$_)} @new_subfields); + next; + } elsif ( $on_existing->{action} eq 'add' ) { + push(@subfields, map {($subcode,$_)} @old_subfields); + push(@subfields, map {($subcode,$_)} @new_subfields); + next; + } elsif ( $on_existing->{action} eq 'add_or_correct' ) { + if ( $similarity_pref ) { + my @corrected_vals = (); + + # correct all old subfields + for ( my $i_new = 0; $i_new <= $#new_subfields; $i_new++ ) { + my $new_val = $new_subfields[$i_new]; + for ( my $i_old = 0; $i_old <= $#old_subfields; $i_old++ ) { + my $old_val = $old_subfields[$i_old]; + next if not defined $old_val or not defined $new_val; + my $similarity = similarity ($old_val, $new_val); + if ( $similarity >= $similarity_pref ) { + $corrected_vals[$i_old] = $new_val; + $new_subfields[$i_new] = undef; + $old_subfields[$i_old] = undef; + last; + } + } + } + + # insert all unchanged old subfields + map { + $corrected_vals[$_] = $old_subfields[$_] + if defined $old_subfields[$_] + } 0 .. $#old_subfields; + + + # append all remaning new subfields + map {push @corrected_vals, $_ if defined} @new_subfields; + + push(@subfields, map {($subcode,$_)} @corrected_vals); + } else { + push(@subfields, map {($subcode,$_)} @old_subfields); + } + } + } else { # default to skip + push(@subfields, map {($subcode,$_)} @old_subfields); + next; + } + # New subfield + } elsif ( not @old_subfields and @new_subfields ) { + my $on_new = GetMarcPermissionsAction('on_new', $filter, @subfield_perms); + if ( defined $on_new->{action} ) { + push @{$log}, + { + rule => $on_new->{rule}, + event => "new", + action => $on_new->{action}, + tag => $tag, + subfieldcode => $subcode, + filter => $filter + }; + if ( $on_new->{action} eq 'skip' ) { + next; + } elsif ( $on_new->{action} eq 'add' ) { + push(@subfields, map {($subcode,$_)} @new_subfields); + next; + } + } + # Removed subfield + } elsif ( @old_subfields and not @new_subfields ) { + my $on_removed = GetMarcPermissionsAction('on_removed', $filter, @subfield_perms); + if ( defined $on_removed->{action} ) { + push @{$log}, + { + rule => $on_removed->{rule}, + event => "removed", + action => $on_removed->{action}, + tag => $tag, + subfieldcode => $subcode, + filter => $filter + }; + if ( $on_removed->{action} eq 'skip' ) { + push(@subfields, ($subcode => @old_subfields)); + next; + } elsif ( $on_removed->{action} eq 'remove' ) { + next; + } + } else { # default to skip + push(@subfields, ($subcode => @old_subfields)); + next; + } + } + } # / for each subfield + $ret_record->insert_grouped_field(MARC::Field->new($tag, $ind[0], $ind[1], @subfields)); + } + # New field + } elsif ( not $old_field and $new_field ) { + my $on_new = GetMarcPermissionsAction('on_new', $filter, @perms); + + if ( defined $on_new->{action} ) { + push @{$log}, + { + rule => $on_new->{rule}, + event => "new", + action => $on_new->{action}, + tag => $tag, + subfieldcode => undef, + filter => $filter + }; + if ( $on_new->{action} eq 'skip' ) { + next; + } elsif ( $on_new->{action} eq 'add' ) { + $ret_record->insert_fields_ordered($new_field); + next; + } + } + # Removed field + } elsif ( $old_field and not $new_field ) { + my $on_removed = GetMarcPermissionsAction('on_removed', $filter, @perms); + if ( defined $on_removed->{action} ) { + push @{$log}, + { + rule => $on_removed->{rule}, + event => "removed", + action => $on_removed->{action}, + tag => $tag, + subfieldcode => undef, + filter => $filter + }; + if ( $on_removed->{action} eq 'skip' ) { + $ret_record->insert_grouped_field($old_field); + next; + } elsif ( $on_removed->{action} eq 'remove' ) { + next; + } + } else { # default to skip + $ret_record->insert_grouped_field($old_field); + next; + } + } + } # / for each field + } # / for each tag + if ( !$nolog && C4::Context->preference("MARCPermissionsLog") ) { + my $log_str = ''; + my $n = $#{$log}; + for my $l ( @{$log} ) { + $log_str .= "{\n"; + my $kn = keys %{$l}; + for my $k ( sort keys %{$l} ) { + if ( $k eq 'filter' ) { + $log_str .= " filter => {\n"; + my $fkn = keys %{ $l->{$k} }; + for my $fk ( sort keys %{ $l->{$k} } ) { + $log_str .= ' ' + . $fk . ' => ' + . $l->{$k}->{$fk} + . ( --$fkn ? ',' : '' ) . "\n"; + } + $log_str .= " }" . ( --$kn ? ',' : '' ) . "\n"; + } + else { + $log_str .= ' ' + . $k . ' => ' + . $l->{$k} + . ( --$kn ? ',' : '' ) . "\n" + if defined $l->{$k}; + } + } + $log_str .= '}' . ( $n-- ? ",\n" : '' ); + } + logaction( "CATALOGUING", "MODIFY", $biblionumber, $log_str ); + } + return $ret_record; + } + return $new_record; +} + + +=head2 GetMarcPermissions + + my $marc_permissions = GetMarcPermissions() + +Loads MARC field permissions from the marc_permissions table. + +Returns: + +=over 4 + +=item C<$marc_permissions> + +hashref with permissions structure for use with GetMarcPermissionsAction. + +=back + +=cut + +sub GetMarcPermissions { + my $dbh = C4::Context->dbh; + my $rule_count = 0; + my %perms = (); + + my $query = ' + SELECT `marc_permissions`.*, + `marc_permissions_modules`.`name`, + `marc_permissions_modules`.`description`, + `marc_permissions_modules`.`specificity` + FROM `marc_permissions` + LEFT JOIN `marc_permissions_modules` ON `module` = `marc_permissions_modules`.`id` + ORDER BY `marc_permissions_modules`.`specificity`, `id` + '; + my $sth = $dbh->prepare($query); + $sth->execute(); + while ( my $row = $sth->fetchrow_hashref ) { + $rule_count++; + if ( defined $row->{tagsubfield} and $row->{tagsubfield} ) { + $perms{ $row->{tagfield} }->{subfields}->{ $row->{tagsubfield} } + ->{on_existing}->{ $row->{name} }->{ $row->{filter} } = + { action => $row->{on_existing}, rule => $row->{'id'} }; + $perms{ $row->{tagfield} }->{subfields}->{ $row->{tagsubfield} } + ->{on_new}->{ $row->{name} }->{ $row->{filter} } = + { action => $row->{on_new}, rule => $row->{'id'} }; + $perms{ $row->{tagfield} }->{subfields}->{ $row->{tagsubfield} } + ->{on_removed}->{ $row->{name} }->{ $row->{filter} } = + { action => $row->{on_removed}, rule => $row->{'id'} }; + } + else { + $perms{ $row->{tagfield} }->{on_existing}->{ $row->{name} } + ->{ $row->{filter} } = + { action => $row->{on_existing}, rule => $row->{'id'} }; + $perms{ $row->{tagfield} }->{on_new}->{ $row->{name} } + ->{ $row->{filter} } = + { action => $row->{on_new}, rule => $row->{'id'} }; + $perms{ $row->{tagfield} }->{on_removed}->{ $row->{name} } + ->{ $row->{filter} } = + { action => $row->{on_removed}, rule => $row->{'id'} }; + } + } + + return unless $rule_count; + return \%perms; +} + +=head2 GetMarcPermissionsAction + + my $action = GetMarcPermissionsAction($event, $filter, @permissions) + +Gets action based on C<$event>, C<$filter> and C<@permissions>. + +=over 4 + +=item C<$event> + +which event: 'on_existing', 'on_new' or 'on_removed' + +=item C<$filter> + +hashref containing at least one filter module from the marc_permissions_modules +table in form {module => filter}. Three predefined filter modules exists: + + * source + * category + * borrower + +=item C<@permissions> + +list of permission structures in order of specificity from least to most +specific. + +=back + +Returns: + +=over 4 + +=item C<$action> + +hashref defining matching action. + +=back + +=cut + +sub GetMarcPermissionsAction { + my $what = shift or return; + my $filter = shift or return; + my @perms = @_; + my $modules = GetMarcPermissionsModules(); + my $action = undef; + + for my $perm (@perms) { + next if not defined $perm; + next if not defined $perm->{$what}; + my $tmp_action = undef; + for my $module ( @{$modules} ) { + my $action_candidate = defined $perm->{$what}->{ $module->{'name'} } ? $perm->{$what}->{ $module->{'name'} } : undef; + if ($action_candidate) { + if ( + defined $filter->{ $module->{'name'} } and + defined $action_candidate->{ $filter->{ $module->{'name'} } }->{action} + ) { + $tmp_action = $perm->{$what}->{ $module->{'name'} }->{ $filter->{ $module->{'name'} } }; + last; + } + elsif (defined $action_candidate->{'*'}->{action} and !$tmp_action) { + $tmp_action = $perm->{$what}->{ $module->{'name'} }->{'*'}; + } + } + } + $action = $tmp_action if defined $tmp_action; + } + return $action; +} + +=head2 GetMarcPermissionsRules + + my $rules = GetMarcPermissionsRules() + +Returns: + +=over 4 + +=item C<$rules> + +array (in list context, arrayref otherwise) of hashrefs from marc_permissions +table in order of module specificity and rule id. + +=back + +=cut + +sub GetMarcPermissionsRules { + my $dbh = C4::Context->dbh; + my @rules = (); + + my $query = ' + SELECT `marc_permissions`.`id`, + `marc_permissions`.`tagfield`, + `marc_permissions`.`tagsubfield`, + `marc_permissions`.`filter`, + `marc_permissions`.`on_existing`, + `marc_permissions`.`on_new`, + `marc_permissions`.`on_removed`, + `marc_permissions_modules`.`name` as `module`, + `marc_permissions_modules`.`description`, + `marc_permissions_modules`.`specificity` + FROM `marc_permissions` + LEFT JOIN `marc_permissions_modules` ON `module` = `marc_permissions_modules`.`id` + ORDER BY `marc_permissions_modules`.`specificity`, `id` + '; + my $sth = $dbh->prepare($query); + $sth->execute(); + while ( my $row = $sth->fetchrow_hashref ) { + push(@rules, $row); + } + + return wantarray ? @rules : \@rules; +} + +=head2 GetMarcPermissionsModules + + my $modules = GetMarcPermissionsModules() + +Returns: + +=over 4 + +=item C<$modules> + +array (in list context, arrayref otherwise) of hashrefs from +marc_permissions_modules table in order of specificity. + +=back + +=cut + +sub GetMarcPermissionsModules { + my $dbh = C4::Context->dbh; + my @modules = (); + + my $query = ' + SELECT * + FROM `marc_permissions_modules` + ORDER BY `specificity` DESC + '; + my $sth = $dbh->prepare($query); + $sth->execute(); + while ( my $row = $sth->fetchrow_hashref ) { + push(@modules, $row); + } + + return wantarray ? @modules : \@modules; +} + +=head2 ModMarcPermissionsRule + + my $success = ModMarcPermissionsRule($id, $fields) + +Modifies rule in the marc_permissions table. + +=over 4 + +=item C<$id> + +rule id to modify + +=item C<$fields> + +hashref defining the table fields + + * tagfield - required + * tagsubfield + * module - required + * filter - required + * on_existing - required + * on_new - required + * on_removed - required + +=back + +Returns: + +=over 4 + +=item C<$success> + +undef if an error occurs, otherwise true. + +=back + +=cut + +sub ModMarcPermissionsRule { + my ($id, $f) = @_; + my $dbh = C4::Context->dbh; + my $query = ' + UPDATE `marc_permissions` + SET + tagfield = ?, + tagsubfield = ?, + module = ?, + filter = ?, + on_existing = ?, + on_new = ?, + on_removed = ? + WHERE + id = ? + '; + my $sth = $dbh->prepare($query); + return $sth->execute ( + $f->{tagfield}, + $f->{tagsubfield}, + $f->{module}, + $f->{filter}, + $f->{on_existing}, + $f->{on_new}, + $f->{on_removed}, + $id + ); +} + +=head2 AddMarcPermissionsRule + + my $success = AddMarcPermissionsRule($fields) + +Add rule to the marc_permissions table. + +=over 4 + +=item C<$fields> + +hashref defining the table fields + + tagfield - required + tagsubfield + module - required + filter - required + on_existing - required + on_new - required + on_removed - required + +=back + +Returns: + +=over 4 + +=item C<$success> + +undef if an error occurs, otherwise true. + +=back + +=cut + +sub AddMarcPermissionsRule { + my $f = shift; + my $dbh = C4::Context->dbh; + my $query = ' + INSERT INTO `marc_permissions` + ( + tagfield, + tagsubfield, + module, + filter, + on_existing, + on_new, + on_removed + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + '; + my $sth = $dbh->prepare($query); + return $sth->execute ( + $f->{tagfield}, + $f->{tagsubfield}, + $f->{module}, + $f->{filter}, + $f->{on_existing}, + $f->{on_new}, + $f->{on_removed} + ); +} + +=head2 DelMarcPermissionsRule + + my $success = DelMarcPermissionsRule($id) + +Deletes rule from the marc_permissions table. + +=over 4 + +=item C<$id> + +rule id to delete + +=back + +Returns: + +=over 4 + +=item C<$success> + +undef if an error occurs, otherwise true. + +=back + +=cut + +sub DelMarcPermissionsRule { + my $id = shift; + my $dbh = C4::Context->dbh; + my $query = ' + DELETE FROM `marc_permissions` + WHERE + id = ? + '; + my $sth = $dbh->prepare($query); + return $sth->execute($id); +} 1; diff --git a/C4/ImportBatch.pm b/C4/ImportBatch.pm index 1b598c7e9d..f274bebc5f 100644 --- a/C4/ImportBatch.pm +++ b/C4/ImportBatch.pm @@ -674,7 +674,7 @@ sub BatchCommitRecords { } $oldxml = $old_marc->as_xml($marc_type); - ModBiblio($marc_record, $recordid, $oldbiblio->{'frameworkcode'}); + ModBiblio($marc_record, $recordid, $oldbiblio->{'frameworkcode'}, {context => {source => 'batchimport'}}); $query = "UPDATE import_biblios SET matched_biblionumber = ? WHERE import_record_id = ?"; if ($item_result eq 'create_new' || $item_result eq 'replace') { diff --git a/C4/Installer/PerlDependencies.pm b/C4/Installer/PerlDependencies.pm index c4f5409009..d4ca008423 100644 --- a/C4/Installer/PerlDependencies.pm +++ b/C4/Installer/PerlDependencies.pm @@ -862,6 +862,11 @@ our $PERL_DEPS = { 'required' => '0', 'min_ver' => '0.07', }, + 'String::Similarity' => { + usage => 'cataloguing', + required => 1, + min_version => '1.04', + }, }; 1; diff --git a/C4/Items.pm b/C4/Items.pm index 26c14a13c7..c16a6769bf 100644 --- a/C4/Items.pm +++ b/C4/Items.pm @@ -400,6 +400,8 @@ sub AddItemBatchFromMarc { logaction("CATALOGUING", "ADD", $itemnumber, "item") if C4::Context->preference("CataloguingLog"); my $new_item_marc = _marc_from_item_hash($item, $frameworkcode, $unlinked_item_subfields); + + # @FIXME: why modify clone record here since not even saving below? $item_field->replace_with($new_item_marc->field($itemtag)); } diff --git a/admin/marc-permissions.pl b/admin/marc-permissions.pl new file mode 100755 index 0000000000..03999cb1e6 --- /dev/null +++ b/admin/marc-permissions.pl @@ -0,0 +1,123 @@ +#!/usr/bin/perl + +# 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; + +# standard or CPAN modules used +use CGI qw ( -utf8 ); +use CGI::Cookie; +use MARC::File::USMARC; + +# Koha modules used +use C4::Context; +use C4::Koha; +use C4::Auth; +use C4::AuthoritiesMarc; +use C4::Output; +use C4::Biblio; +use C4::ImportBatch; +use C4::Matcher; +use C4::BackgroundJob; +use C4::Labels::Batch; + +my $script_name = "/cgi-bin/koha/admin/marc-permissions.pl"; + +my $input = new CGI; +my $op = $input->param('op') || ''; + +my ( $template, $loggedinuser, $cookie ) = get_template_and_user( + { + template_name => "admin/marc-permissions.tt", + query => $input, + type => "intranet", + authnotrequired => 0, + flagsrequired => { parameters => 'parameters_remaining_permissions' }, + debug => 1, + } +); + +my %cookies = parse CGI::Cookie($cookie); +our $sessionID = $cookies{'CGISESSID'}->value; + +my $rules; +if ( $op eq "remove" ) { + $template->{VARS}->{removeConfirm} = 1; + my @removeIDs = $input->multi_param('batchremove'); + push( @removeIDs, scalar $input->param('id') ) if $input->param('id'); + + $rules = GetMarcPermissionsRules(); + for my $removeID (@removeIDs) { + map { $_->{'remove'} = 1 if $_->{'id'} == $removeID } @{$rules}; + } + +} +elsif ( $op eq "doremove" ) { + my @removeIDs = $input->multi_param('batchremove'); + push( @removeIDs, scalar $input->param('id') ) if $input->param('id'); + for my $removeID (@removeIDs) { + DelMarcPermissionsRule($removeID); + } + + $rules = GetMarcPermissionsRules(); + +} +elsif ( $op eq "edit" ) { + $template->{VARS}->{edit} = 1; + my $id = $input->param('id'); + $rules = GetMarcPermissionsRules(); + map { $_->{'edit'} = 1 if $_->{'id'} == $id } @{$rules}; + +} +elsif ( $op eq "doedit" ) { + my $id = $input->param('id'); + my $fields = { + module => scalar $input->param('module'), + tagfield => scalar $input->param('tagfield'), + tagsubfield => scalar $input->param('tagsubfield'), + filter => scalar $input->param('filter'), + on_existing => scalar $input->param('on_existing'), + on_new => scalar $input->param('on_new'), + on_removed => scalar $input->param('on_removed') + }; + + ModMarcPermissionsRule( $id, $fields ); + $rules = GetMarcPermissionsRules(); + +} +elsif ( $op eq "add" ) { + my $fields = { + module => scalar $input->param('module'), + tagfield => scalar $input->param('tagfield'), + tagsubfield => scalar $input->param('tagsubfield'), + filter => scalar $input->param('filter'), + on_existing => scalar $input->param('on_existing'), + on_new => scalar $input->param('on_new'), + on_removed => scalar $input->param('on_removed') + }; + + AddMarcPermissionsRule($fields); + $rules = GetMarcPermissionsRules(); + +} +else { + $rules = GetMarcPermissionsRules(); +} +my $modules = GetMarcPermissionsModules(); +$template->param( rules => $rules, modules => $modules ); + +output_html_with_http_headers $input, $cookie, $template->output; diff --git a/cataloguing/addbiblio.pl b/cataloguing/addbiblio.pl index 013c312bb2..752ea99c93 100755 --- a/cataloguing/addbiblio.pl +++ b/cataloguing/addbiblio.pl @@ -836,9 +836,17 @@ if ( $op eq "addbiblio" ) { my $oldbibitemnum; if (C4::Context->preference("BiblioAddsAuthorities")){ BiblioAutoLink( $record, $frameworkcode ); - } + } if ( $is_a_modif ) { - ModBiblio( $record, $biblionumber, $frameworkcode ); + my ($member) = C4::Members::GetMember('borrowernumber' => $loggedinuser); + ModBiblio( $record, $biblionumber, $frameworkcode, { + context => { + source => $z3950 ? 'z39.50' : 'intranet', + category => $member->{'category_type'}, + borrower => $loggedinuser + } + } + ); } else { ( $biblionumber, $oldbibitemnum ) = AddBiblio( $record, $frameworkcode ); @@ -931,6 +939,7 @@ elsif ( $op eq "delete" ) { $template->param( biblionumberdata => $biblionumber, op => $op, + z3950 => $z3950 ); if ( $op eq "duplicate" ) { $biblionumber = ""; diff --git a/help.pl b/help.pl index ad86a2da29..86855d0248 100755 --- a/help.pl +++ b/help.pl @@ -24,6 +24,7 @@ use C4::Output; # use C4::Auth; use C4::Context; use CGI qw ( -utf8 ); +use C4::Biblio; sub _help_template_file_of_url { my $url = shift; @@ -75,4 +76,8 @@ if ( $help_version =~ m|^(\d+)\.(\d{2}).*$| ) { } $template->param( helpVersion => $help_version ); +my $rules = GetMarcPermissionsRules(); +my $modules = GetMarcPermissionsModules(); +$template->param( rules => $rules, modules => $modules ); + output_html_with_http_headers $query, "", $template->output; diff --git a/installer/data/mysql/atomicupdate/bug_14957-marc-permissions-syspref.sql b/installer/data/mysql/atomicupdate/bug_14957-marc-permissions-syspref.sql new file mode 100644 index 0000000000..d697fc3721 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug_14957-marc-permissions-syspref.sql @@ -0,0 +1,3 @@ +INSERT IGNORE INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `type` ) VALUES ('MARCPermissions','0','','Use the MARC permissions system to decide what actions to take for each field when modifying records.','YesNo'); +INSERT IGNORE INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `type` ) VALUES ('MARCPermissionsLog','0','','Write MARC permissions rule evaluations to the system log when applying rules for MARC field modifications.','YesNo'); +INSERT IGNORE INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `type` ) VALUES ('MARCPermissionsCorrectionSimilarity','90','','New field value is considered a correction if it is similar to the old to the specified percentage.','Integer'); diff --git a/installer/data/mysql/atomicupdate/bug_14957-marc-permissions.sql b/installer/data/mysql/atomicupdate/bug_14957-marc-permissions.sql new file mode 100644 index 0000000000..ffbc6336c2 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug_14957-marc-permissions.sql @@ -0,0 +1,31 @@ +DROP TABLE IF EXISTS `marc_permissions`; +DROP TABLE IF EXISTS `marc_permissions_modules`; + +CREATE TABLE `marc_permissions_modules` ( + `id` int(11) NOT NULL auto_increment, + `name` varchar(24) NOT NULL, + `description` varchar(255), + `specificity` int(11) NOT NULL DEFAULT 0, -- higher specificity will override rules with lower specificity + PRIMARY KEY(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + +-- a couple of useful default filter modules +-- these are used in various scripts, so don't remove them if you don't know +-- what you're doing. +-- New filter modules can be added here when needed +INSERT INTO `marc_permissions_modules` VALUES(NULL, 'source', 'source from where modification request was sent', 0); +INSERT INTO `marc_permissions_modules` VALUES(NULL, 'category', 'categorycode of user who requested modification', 1); +INSERT INTO `marc_permissions_modules` VALUES(NULL, 'borrower', 'borrowernumber of user who requested modification', 2); + +CREATE TABLE `marc_permissions` ( + `id` int(11) NOT NULL auto_increment, + `tagfield` varchar(255) NOT NULL, -- can be regexe, so need > 3 chars + `tagsubfield` varchar(255) DEFAULT NULL, -- can be regex, so need > 1 char + `module` int(11) NOT NULL, + `filter` varchar(255) NOT NULL, + `on_existing` ENUM('skip', 'overwrite', 'add', 'add_or_correct') DEFAULT NULL, + `on_new` ENUM('skip', 'add') DEFAULT NULL, + `on_removed` ENUM('skip', 'remove') DEFAULT NULL, + PRIMARY KEY(`id`), + CONSTRAINT `marc_permissions_ibfk1` FOREIGN KEY (`module`) REFERENCES `marc_permissions_modules` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc index 9f8f4f65a1..41bb194050 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc @@ -50,6 +50,7 @@
  • Record matching rules
  • OAI sets configuration
  • Item search fields
  • +
  • MARC field permissions
  • Acquisition parameters
    diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt index 0d43e6f949..26635a6bfb 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt @@ -89,6 +89,8 @@
    Search engine configuration
    Manage indexes, facets, and their mappings to MARC fields and subfields.
    [% END %] +
    MARC field permissions
    +
    Managed MARC field permissions

    Acquisition parameters

    diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-permissions.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-permissions.tt new file mode 100644 index 0000000000..ff0670a69f --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-permissions.tt @@ -0,0 +1,314 @@ +[% USE Koha %] +[% INCLUDE 'doc-head-open.inc' %] +Koha › Administration › MARC field permissions +[% INCLUDE 'doc-head-close.inc' %] + +[% INCLUDE 'datatables.inc' %] + + + + + + +[% INCLUDE 'header.inc' %] +[% INCLUDE 'cat-search.inc' %] + + + +
    +
    +
    +
    + +

    Manage MARC field permissions

    + +[% UNLESS Koha.Preference( 'MARCPermissions' ) %] +
    + The MARCPermissions preference is not set, don't forget to enable it for rules to take effect. +
    +[% END %] +[% IF removeConfirm %] +
    +

    Remove rule?

    +

    Are you sure you want to remove the selected rule(s)?

    + +
    + +
    + +
    +[% END %] + +
    + + + + + + + + + + + + + + [% UNLESS edit %] + + + + + + + + + + + + + + + [% END %] + + [% FOREACH rule IN rules %] + + [% IF rule.edit %] + + + + + + + + + + + [% ELSE %] + + + + + + + + + + + [% END %] + + [% END %] + +
    RuleTagSubfield tagModuleFilterOn ExistingOn NewOn RemovedActions 
      + + + + + + + +
    [% rule.id %] + + + + + + + + + + + [% rule.id %][% rule.tagfield %][% rule.tagsubfield%][% rule.module %][% rule.filter %][% rule.on_existing %][% rule.on_new %][% rule.on_removed %] + Delete + Edit + + [% IF rule.remove %] + + [% ELSE %] + + [% END %] +
    +
    + +
    + +
    + +
    +
    +
    +[% INCLUDE 'admin-menu.inc' %] +
    +
    +[% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/cataloguing.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/cataloguing.pref index 1aeadf9860..5fe2090d9f 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/cataloguing.pref +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/cataloguing.pref @@ -234,24 +234,12 @@ Cataloging: Exporting: - - - Include following fields when exporting BibTeX, - - pref: BibtexExportAdditionalFields - type: textarea - - "Use one line per tag in the format BT_TAG: TAG$SUBFIELD ( e.g. lccn: 010$a )" - - "
    " - - "To specificy multiple marc tags/subfields as targets for a repeating BibTex tag, use the following format: BT_TAG: [TAG2$SUBFIELD1, TAG2$SUBFIELD2] ( e.g. notes: [501$a, 505$g] )" - - "
    " - - "All values of repeating tags and subfields will be printed with the given BibTeX tag." - - "
    " - - "Use '@' ( with quotes ) as the BT_TAG to replace the bibtex record type with a field value of your choosing." - - - - Include following fields when exporting RIS, - - pref: RisExportAdditionalFields - type: textarea - - "Use one line per tag in the format RIS_TAG: TAG$SUBFIELD ( e.g. LC: 010$a )" - - "
    " - - "To specificy multiple marc tags/subfields as targets for a repeating RIS tag, use the following format: RIS_TAG: [TAG2$SUBFIELD1, TAG2$SUBFIELD2] ( e.g. NT: [501$a, 505$g] )" - - "
    " - - "All values of repeating tags and subfields will be printed with the given RIS tag." - - "
    " - - "Use of TY ( record type ) as a key will replace the default TY with the field value of your choosing." + - When importing records + - pref: MARCPermissions + choices: + yes: "use" + no: "don't use" + - MARC permissions rules to decide which action to take for each field. + - When add_or_correct, consider a field modification to be a correction if the old and new value has a similarity of + - pref: MARCPermissionsCorrectionSimilarity + - %. If similarity falls below this value, the new value will be treated as a new field. diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/logs.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/logs.pref index cb896c61a5..0c2a1ffe8f 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/logs.pref +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/logs.pref @@ -72,6 +72,12 @@ Logging: on: Log off: "Don't log" - when reports are added, deleted or changed. + - + - pref: MARCPermissionsLog + choices: + on: Log + off: "Don't log" + - MARC permissions rule evaluations when applying rules for MARC field modifications. Debugging: - - pref: DumpTemplateVarsIntranet 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 7a50096593..a053019ab7 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt @@ -545,6 +545,7 @@ function Changefwk() { [% END %] + diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/help/marc-permissions.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/help/marc-permissions.tt new file mode 100644 index 0000000000..9f657e659b --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/help/marc-permissions.tt @@ -0,0 +1,148 @@ +[% INCLUDE 'help-top.inc' %] + +

    Manage MARC field permissions

    + +

    Rule evaluation

    +

    Rules are evaluated from least specific to most specific. This means that a more specific rule will override a less specific rule. * is less specific than a regular expression. A regular expression is less specific than a normal field name (e.g. 245). A Subfield tag is more specific than a Tag.

    +

    To add a field-specific rule (i.e. not a subfield), leave the Subfield tag field blank.

    + +

    Defaults

    +

    Default action when no rules exist is to replace the old record with the new record. Same as if MARCPermissions is disabled.

    +

    Default action when no matching rule is found is to leave a field unchanged (skip). If you wish to changed the default actions for fields and subfields, please add wildcard rules.

    + +

    Wildcards

    +

    * can be used as a wildcard for Tag, Subfield tag and Filter. * is considered less specific than a non wildcard value, and thus will be overridden by a non wildcard value (e.g. "100", "a", etc).

    + +

    Regular expressions

    +

    Regular expressions can be used in both the Tag and Subfield tag fields. Beware though, using regular expressions may create overlapping matches, in which case they will be applied in a sorted order.

    + +

    Example rules

    +

    Following is an example rule set in order of specificity.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TagSubfield tagModuleFilterDescription
    *[% modules.0.name %]*Match any field regardless of [% modules.0.name %]
    **[% modules.0.name %]*Match any subfield regardless of [% modules.0.name %]
    245*[% modules.0.name %]z39.50Match any subfield under 245 when [% modules.0.name %] is z39.50
    *b[% modules.0.name %]z39.50Match subfield b regardless of field when [% modules.0.name %] is z39.50
    500[a-d][% modules.0.name %]z39.50Match subfields a, b, c, d when field is 500 and [% modules.0.name %] is z39.50
    5..a[% modules.0.name %]z39.50Match subfield a when field matches the regular expression 5.. (500-599) and [% modules.0.name %] is z39.50
    245a[% modules.0.name %]z39.50Match subfield a when field is 500 and [% modules.0.name %] is z39.50
    + +
    + +

    Available filter modules

    +

    Filters cannot be regular expressions. Please use a single * as a wildcard if need.

    + + + + + + + + + [% FOREACH module IN modules %] + + + + + + [% END %] + +
    SpecificityModuleDescription
    [% module.specificity %][% module.name %][% module.description %]
    + +
    + +

    Available sources

    +

    The following sources currently implement MARCPermissions.

    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameDescription
    bulkmarcimportbin/migration_tools/bulkmarcimport.pl
    import_lexilebin/migration_tools/import_lexile.pl
    z39.50Import from Z39.50 search in browser
    intranetModifications from intranet in browser
    batchmodBatch record modification in browser
    + +
    + +

    Indicators

    +

    Indicators can be addressed as i1 and i2 in the Subfield tag field. Some actions might not be available for indicators, in which case they will be disabled.

    + +
    + +

    Logging

    +

    If MARCPermissionsLog is enabled, log entries for each record modification will be available in the Modification log in the Catalog module under the Modify action. This can be very helpful when debugging rule sets.

    +

    The administration area is where you set all of your preferences for the system. Preference are broken down into several categories, detailed below.

    + +

    See the full documentation for Koha in the manual (online).

    + +[% INCLUDE 'help-bottom.inc' %] diff --git a/misc/migration_tools/bulkmarcimport.pl b/misc/migration_tools/bulkmarcimport.pl index dc2cae4182..497cd93ef9 100755 --- a/misc/migration_tools/bulkmarcimport.pl +++ b/misc/migration_tools/bulkmarcimport.pl @@ -417,7 +417,7 @@ RECORD: while ( ) { } # create biblio, unless we already have it ( either match or isbn ) if ($biblionumber) { - eval{$biblioitemnumber=GetBiblioData($biblionumber)->{biblioitemnumber};}; + eval { ( $biblionumber, $biblioitemnumber ) = ModBiblio( $record, $biblionumber, GetFrameworkCode($biblionumber), {context => {source => 'bulkmarcimport'}}) }; if ($update) { eval { ( $biblionumber, $biblioitemnumber ) = ModBiblio( $record, $biblionumber, GetFrameworkCode($biblionumber) ) }; if ($@) { @@ -805,4 +805,3 @@ from the migration_tools directory. =back =cut - diff --git a/misc/migration_tools/import_lexile.pl b/misc/migration_tools/import_lexile.pl index e9fad46503..b419c8d630 100755 --- a/misc/migration_tools/import_lexile.pl +++ b/misc/migration_tools/import_lexile.pl @@ -151,6 +151,7 @@ while ( my $row = $csv->getline_hr($fh) ) { foreach my $biblionumber (@biblionumbers) { $counter++; my $record = GetMarcBiblio($biblionumber); + my $frameworkcode = GetFrameworkCode($biblionumber); if ($verbose) { say "Found matching record! Biblionumber: $biblionumber"; @@ -200,7 +201,7 @@ while ( my $row = $csv->getline_hr($fh) ) { $record->append_fields($field); } - ModBiblio( $record, $biblionumber ) unless ( $test ); + ModBiblio( $record, $biblionumber, $frameworkcode, {context => {source => 'import_lexile'}} ) unless ( $test ); } } diff --git a/t/db_dependent/Biblio/MARCPermissions.t b/t/db_dependent/Biblio/MARCPermissions.t new file mode 100755 index 0000000000..d942680c14 --- /dev/null +++ b/t/db_dependent/Biblio/MARCPermissions.t @@ -0,0 +1,280 @@ +#!/usr/bin/perl + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Test::More tests => 16; +use Test::MockModule; + +use MARC::Record; +use Data::Dumper; + +BEGIN { + use_ok('C4::Biblio'); +} + +# Start transaction +my $dbh = C4::Context->dbh; +$dbh->{AutoCommit} = 0; +$dbh->{RaiseError} = 1; + +C4::Context->set_preference( 'MARCPermissions', '1'); +C4::Context->set_preference( 'MARCPermissionsLog', '1'); +C4::Context->set_preference( 'MARCPermissionsCorrectionSimilarity', '90'); + +# Create a record +my $record = MARC::Record->new(); +$record->append_fields ( + MARC::Field->new('008', '12345'), + MARC::Field->new('100', '','', 'a' => 'field data for 100 a without indicators'), + MARC::Field->new('250', '','', 'a' => '250 bottles of beer on the wall'), + MARC::Field->new('245', '1','2', 'a' => 'field data for 245 a with indicators 12'), + MARC::Field->new('500', '1','1', 'a' => 'the lazy programmer jumps over the quick brown tests'), + MARC::Field->new('500', '2','2', 'a' => 'the quick brown test jumps over the lazy programmers'), +); + +# Add record to DB +my ($biblionumber, $biblioitemnumber) = AddBiblio($record, ''); + +my $modules = GetMarcPermissionsModules(); + +############################################################################## +# Test overwrite rule +my $mod_record = MARC::Record->new(); +$mod_record->append_fields ( + MARC::Field->new('008', '12345'), + MARC::Field->new('100', '','', 'a' => 'field data for 100 a without indicators'), + MARC::Field->new('245', '1','2', 'a' => 'field data for 245 a with indicators 12'), + MARC::Field->new('500', '1','1', 'a' => 'this field has now been changed'), + MARC::Field->new('500', '2','2', 'a' => 'and so have this field'), +); + +# Clear MARC permission rules from DB +DelMarcPermissionsRule($_->{id}) for GetMarcPermissionsRules(); + +# Add MARC permission rules to DB +AddMarcPermissionsRule({ + module => $modules->[0]->{'id'}, + tagfield => '*', + tagsubfield => '', + filter => '*', + on_existing => 'overwrite', + on_new => 'add', + on_removed => 'remove' +}); + +my @log = (); +my $new_record = ApplyMarcPermissions({ + biblionumber => $biblionumber, + record => $mod_record, + frameworkcode => '', + filter => {$modules->[0]->{'name'} => 'foo'}, + log => \@log + }); + +my @a500 = $new_record->field('500'); +is ($a500[0]->subfield('a'), 'this field has now been changed', 'old field is replaced when overwrite'); +is ($a500[1]->subfield('a'), 'and so have this field', 'old field is replaced when overwrite'); + +############################################################################## +# Test remove rule +is ($new_record->field('250'), undef, 'removed field is removed'); + +############################################################################## +# Test skip rule +$mod_record = MARC::Record->new(); +$mod_record->append_fields ( + MARC::Field->new('008', '12345'), + MARC::Field->new('100', '','', 'a' => 'field data for 100 a without indicators'), + MARC::Field->new('245', '1','2', 'a' => 'field data for 245 a with indicators 12'), + MARC::Field->new('500', '1','1', 'a' => 'this should not show'), + MARC::Field->new('500', '2','2', 'a' => 'and neither should this'), +); + +AddMarcPermissionsRule({ + module => $modules->[0]->{'id'}, + tagfield => '500', + tagsubfield => '*', + filter => '*', + on_existing => 'skip', + on_new => 'skip', + on_removed => 'skip' +}); + +@log = (); +$new_record = ApplyMarcPermissions({ + biblionumber => $biblionumber, + record => $mod_record, + frameworkcode => '', + filter => {$modules->[0]->{'name'} => 'foo'}, + log => \@log + }); + +@a500 = $new_record->field('500'); +is ($a500[0]->subfield('a'), 'the lazy programmer jumps over the quick brown tests', 'old field is kept when skip'); +is ($a500[1]->subfield('a'), 'the quick brown test jumps over the lazy programmers', 'old field is kept when skip'); + +############################################################################## +# Test add rule +$mod_record = MARC::Record->new(); +$mod_record->append_fields ( + MARC::Field->new('008', '12345'), + MARC::Field->new('100', '','', 'a' => 'field data for 100 a without indicators'), + MARC::Field->new('250', '','', 'a' => '250 bottles of beer on the wall'), + #MARC::Field->new('245', '1','2', 'a' => 'field data for 245 a with indicators 12'), + MARC::Field->new('245', '1','2', 'a' => 'some new fun value'), + MARC::Field->new('500', '1','1', 'a' => 'the lazy programmer jumps over the quick brown tests'), + MARC::Field->new('500', '2','2', 'a' => 'the quick brown test jumps over the lazy programmers'), +); + +AddMarcPermissionsRule({ + module => $modules->[0]->{'id'}, + tagfield => '245', + tagsubfield => '*', + filter => '*', + on_existing => 'add', + on_new => 'add', + on_removed => 'skip' +}); + + +@log = (); +$new_record = ApplyMarcPermissions({ + biblionumber => $biblionumber, + record => $mod_record, + frameworkcode => '', + filter => {$modules->[0]->{'name'} => 'foo'}, + log => \@log + }); + +my @a245 = $new_record->field('245')->subfield('a'); +is ($a245[0], 'field data for 245 a with indicators 12', 'old field is kept when adding new'); +is ($a245[1], 'some new fun value', 'new field is added'); + +############################################################################## +# Test add_or_correct rule +$mod_record = MARC::Record->new(); +$mod_record->append_fields ( + MARC::Field->new('008', '12345'), + #MARC::Field->new('100', '','', 'a' => 'field data for 100 a without indicators'), + MARC::Field->new('100', '','', 'a' => 'a very different value'), + MARC::Field->new('250', '','', 'a' => '250 bottles of beer on the wall'), + #MARC::Field->new('245', '1','2', 'a' => 'field data for 245 a with indicators 12'), + MARC::Field->new('245', '1','2', 'a' => 'Field data for 245 a with indicators 12', 'a' => 'some very different value'), + MARC::Field->new('500', '1','1', 'a' => 'the lazy programmer jumps over the quick brown tests'), + MARC::Field->new('500', '2','2', 'a' => 'the quick brown test jumps over the lazy programmers'), +); + +AddMarcPermissionsRule({ + module => $modules->[0]->{'id'}, + tagfield => '(100|245)', + tagsubfield => '*', + filter => '*', + on_existing => 'add_or_correct', + on_new => 'add', + on_removed => 'skip' +}); + +@log = (); +$new_record = ApplyMarcPermissions({ + biblionumber => $biblionumber, + record => $mod_record, + frameworkcode => '', + filter => {$modules->[0]->{'name'} => 'foo'}, + log => \@log + }); + +@a245 = $new_record->field('245')->subfield('a'); +is ($a245[0], 'Field data for 245 a with indicators 12', 'add_or_correct modifies field when a correction'); +is ($a245[1], 'some very different value', 'add_or_correct adds field when not a correction'); + +my @a100 = $new_record->field('100')->subfield('a'); +is ($a100[0], 'field data for 100 a without indicators', 'add_or_correct keeps old field when not a correction'); +is ($a100[1], 'a very different value', 'add_or_correct adds field when not a correction'); + +############################################################################## +# Test rule evaluation order +$mod_record = MARC::Record->new(); +$mod_record->append_fields ( + MARC::Field->new('008', '12345'), + MARC::Field->new('100', '','', 'a' => 'field data for 100 a without indicators'), + MARC::Field->new('250', '','', 'a' => 'take one down, pass it around'), + MARC::Field->new('245', '1','2', 'a' => 'field data for 245 a with indicators 12'), + MARC::Field->new('500', '1','1', 'a' => 'the lazy programmer jumps over the quick brown tests'), + MARC::Field->new('500', '2','2', 'a' => 'the quick brown test jumps over the lazy programmers'), +); + + +DelMarcPermissionsRule($_->{id}) for GetMarcPermissionsRules(); + +AddMarcPermissionsRule({ + module => $modules->[0]->{'id'}, + tagfield => '*', + tagsubfield => '*', + filter => '*', + on_existing => 'skip', + on_new => 'skip', + on_removed => 'skip' +}); +AddMarcPermissionsRule({ + module => $modules->[0]->{'id'}, + tagfield => '250', + tagsubfield => '*', + filter => '*', + on_existing => 'overwrite', + on_new => 'skip', + on_removed => 'skip' +}); +AddMarcPermissionsRule({ + module => $modules->[0]->{'id'}, + tagfield => '*', + tagsubfield => 'a', + filter => '*', + on_existing => 'add_or_correct', + on_new => 'skip', + on_removed => 'skip' +}); +AddMarcPermissionsRule({ + module => $modules->[0]->{'id'}, + tagfield => '250', + tagsubfield => 'a', + filter => '*', + on_existing => 'add', + on_new => 'skip', + on_removed => 'skip' +}); + +@log = (); +$new_record = ApplyMarcPermissions({ + biblionumber => $biblionumber, + record => $mod_record, + frameworkcode => '', + filter => {$modules->[0]->{'name'} => 'foo'}, + log => \@log + }); + +my @rule = grep { $_->{tag} eq '250' and $_->{subfieldcode} eq 'a' } @log; +is(scalar @rule, 1, 'only one rule applied'); +is($rule[0]->{event}.':'.$rule[0]->{action}, 'existing:add', 'most specific rule used'); + +my @a250 = $new_record->field('250')->subfield('a'); +is ($a250[0], '250 bottles of beer on the wall', 'most specific rule is applied, original field kept'); +is ($a250[1], 'take one down, pass it around', 'most specific rule is applied, new field added'); + +$dbh->rollback; + +1; diff --git a/tools/batch_record_modification.pl b/tools/batch_record_modification.pl index 6ea0317955..9f315272c7 100755 --- a/tools/batch_record_modification.pl +++ b/tools/batch_record_modification.pl @@ -156,7 +156,7 @@ if ( $op eq 'form' ) { my ( $job ); if ( $runinbackground ) { my $job_size = scalar( @record_ids ); - $job = C4::BackgroundJob->new( $sessionID, "FIXME", '/cgi-bin/koha/tools/batch_record_modification.pl', $job_size ); + $job = C4::BackgroundJob->new( $sessionID, "FIXME", $ENV{SCRIPT_NAME}, $job_size ); my $job_id = $job->id; if (my $pid = fork) { $dbh->{InactiveDestroy} = 1; @@ -168,7 +168,7 @@ if ( $op eq 'form' ) { } elsif (defined $pid) { close STDOUT; } else { - warn "fork failed while attempting to run tools/batch_record_modification.pl as a background job"; + warn "fork failed while attempting to run $ENV{'SCRIPT_NAME'} as a background job"; exit 0; } } @@ -192,7 +192,14 @@ if ( $op eq 'form' ) { my $record = GetMarcBiblio( $biblionumber ); ModifyRecordWithTemplate( $mmtid, $record ); my $frameworkcode = C4::Biblio::GetFrameworkCode( $biblionumber ); - ModBiblio( $record, $biblionumber, $frameworkcode ); + my ($member) = C4::Members::GetMember('borrowernumber' => $loggedinuser); + ModBiblio( $record, $biblionumber, $frameworkcode, + { + source => 'batchmod', + category => $member->{'category_type'}, + borrower => $loggedinuser + } + ); }; if ( $error and $error != 1 or $@ ) { # ModBiblio returns 1 if everything as gone well push @messages, { -- 2.11.0