Bug 28445: Use the task queue for the batch delete and update items tool
[koha.git] / Koha / Items.pm
1 package Koha::Items;
2
3 # Copyright ByWater Solutions 2014
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use Modern::Perl;
21 use Array::Utils qw( array_minus );
22 use List::MoreUtils qw( uniq );
23
24 use C4::Context;
25 use C4::Biblio qw( GetMarcStructure GetMarcFromKohaField );
26
27 use Koha::Database;
28 use Koha::SearchEngine::Indexer;
29
30 use Koha::Item::Attributes;
31 use Koha::Item;
32 use Koha::CirculationRules;
33
34 use base qw(Koha::Objects);
35
36 use Koha::SearchEngine::Indexer;
37
38 =head1 NAME
39
40 Koha::Items - Koha Item object set class
41
42 =head1 API
43
44 =head2 Class methods
45
46 =cut
47
48 =head3 filter_by_for_hold
49
50     my $filtered_items = $items->filter_by_for_hold;
51
52 Return the items of the set that are *potentially* holdable.
53
54 Caller has the responsibility to call C4::Reserves::CanItemBeReserved before
55 placing a hold on one of those items.
56
57 =cut
58
59 sub filter_by_for_hold {
60     my ($self) = @_;
61
62     my @hold_not_allowed_itypes = Koha::CirculationRules->search(
63         {
64             rule_name    => 'holdallowed',
65             branchcode   => undef,
66             categorycode => undef,
67             rule_value   => 'not_allowed',
68         }
69     )->get_column('itemtype');
70     push @hold_not_allowed_itypes, Koha::ItemTypes->search({ notforloan => 1 })->get_column('itemtype');
71
72     my $params = {
73         itemlost   => 0,
74         withdrawn  => 0,
75         notforloan => { '<=' => 0 },    # items with negative or zero notforloan value are holdable
76         ( C4::Context->preference('AllowHoldsOnDamagedItems')? (): ( damaged => 0 ) ),
77     };
78
79     if ( C4::Context->preference("item-level_itypes") ) {
80         return $self->search(
81             {
82                 %$params,
83                 itype        => { -not_in => \@hold_not_allowed_itypes },
84             }
85         );
86     } else {
87         return $self->search(
88             {
89                 %$params,
90                 'biblioitem.itemtype' => { -not_in => \@hold_not_allowed_itypes },
91             },
92             {
93                 join => 'biblioitem',
94             }
95         );
96     }
97 }
98
99 =head3 filter_by_visible_in_opac
100
101     my $filered_items = $items->filter_by_visible_in_opac(
102         {
103             [ patron => $patron ]
104         }
105     );
106
107 Returns a new resultset, containing those items that are not expected to be hidden in OPAC
108 for the passed I<Koha::Patron> object that is passed.
109
110 The I<OpacHiddenItems>, I<hidelostitems> and I<OpacHiddenItemsExceptions> system preferences
111 are honoured.
112
113 =cut
114
115 sub filter_by_visible_in_opac {
116     my ($self, $params) = @_;
117
118     my $patron = $params->{patron};
119
120     my $result = $self;
121
122     # Filter out OpacHiddenItems unless disabled by OpacHiddenItemsExceptions
123     unless ( $patron and $patron->category->override_hidden_items ) {
124         my $rules = C4::Context->yaml_preference('OpacHiddenItems') // {};
125
126         my $rules_params;
127         foreach my $field ( keys %$rules ) {
128             $rules_params->{$field} =
129               [ { '-not_in' => $rules->{$field} }, undef ];
130         }
131
132         $result = $result->search( $rules_params );
133     }
134
135     if (C4::Context->preference('hidelostitems')) {
136         $result = $result->filter_out_lost;
137     }
138
139     return $result;
140 }
141
142 =head3 filter_out_lost
143
144     my $filered_items = $items->filter_out_lost;
145
146 Returns a new resultset, containing those items that are not marked as lost.
147
148 =cut
149
150 sub filter_out_lost {
151     my ($self) = @_;
152
153     my $params = { itemlost => 0 };
154
155     return $self->search( $params );
156 }
157
158
159 =head3 move_to_biblio
160
161  $items->move_to_biblio($to_biblio);
162
163 Move items to a given biblio.
164
165 =cut
166
167 sub move_to_biblio {
168     my ( $self, $to_biblio ) = @_;
169
170     my $biblionumbers = { $to_biblio->biblionumber => 1 };
171     while ( my $item = $self->next() ) {
172         $biblionumbers->{ $item->biblionumber } = 1;
173         $item->move_to_biblio( $to_biblio, { skip_record_index => 1 } );
174     }
175     my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX });
176     for my $biblionumber ( keys %{$biblionumbers} ) {
177         $indexer->index_records( $biblionumber, "specialUpdate", "biblioserver" );
178     }
179 }
180
181 =head3 batch_update
182
183     Koha::Items->search->batch_update
184         {
185             new_values => {
186                 itemnotes => $new_item_notes,
187                 k         => $k,
188             },
189             regex_mod => {
190                 itemnotes_nonpublic => {
191                     search => 'foo',
192                     replace => 'bar',
193                     modifiers => 'gi',
194                 },
195             },
196             exclude_from_local_holds_priority => 1|0,
197             callback => sub {
198                 # increment something here
199             },
200         }
201     );
202
203 Batch update the items.
204
205 Returns ( $report, $self )
206 Report has 2 keys:
207   * modified_itemnumbers - list of the modified itemnumbers
208   * modified_fields - number of fields modified
209
210 Parameters:
211
212 =over
213
214 =item new_values
215
216 Allows to set a new value for given fields.
217 The key can be one of the item's column name, or one subfieldcode of a MARC subfields not linked with a Koha field
218
219 =item regex_mod
220
221 Allows to modify existing subfield's values using a regular expression
222
223 =item exclude_from_local_holds_priority
224
225 Set the passed boolean value to items.exclude_from_local_holds_priority
226
227 =item callback
228
229 Callback function to call after an item has been modified
230
231 =back
232
233 =cut
234
235 sub batch_update {
236     my ( $self, $params ) = @_;
237
238     my $regex_mod = $params->{regex_mod} || {};
239     my $new_values = $params->{new_values} || {};
240     my $exclude_from_local_holds_priority = $params->{exclude_from_local_holds_priority};
241     my $callback = $params->{callback};
242
243     my (@modified_itemnumbers, $modified_fields);
244     my $i;
245     while ( my $item = $self->next ) {
246
247         my $modified_holds_priority = 0;
248         if ( defined $exclude_from_local_holds_priority ) {
249             if(!defined $item->exclude_from_local_holds_priority || $item->exclude_from_local_holds_priority != $exclude_from_local_holds_priority) {
250                 $item->exclude_from_local_holds_priority($exclude_from_local_holds_priority)->store;
251                 $modified_holds_priority = 1;
252             }
253         }
254
255         my $modified = 0;
256         my $new_values = {%$new_values};    # Don't modify the original
257
258         my $old_values = $item->unblessed;
259         if ( $item->more_subfields_xml ) {
260             $old_values = {
261                 %$old_values,
262                 %{$item->additional_attributes->to_hashref},
263             };
264         }
265
266         for my $attr ( keys %$regex_mod ) {
267             my $old_value = $old_values->{$attr};
268
269             next unless $old_value;
270
271             my $value = apply_regex(
272                 {
273                     %{ $regex_mod->{$attr} },
274                     value => $old_value,
275                 }
276             );
277
278             $new_values->{$attr} = $value;
279         }
280
281         for my $attribute ( keys %$new_values ) {
282             next if $attribute eq 'more_subfields_xml'; # Already counted before
283
284             my $old = $old_values->{$attribute};
285             my $new = $new_values->{$attribute};
286             $modified++
287               if ( defined $old xor defined $new )
288               || ( defined $old && defined $new && $new ne $old );
289         }
290
291         { # Dealing with more_subfields_xml
292
293             my $frameworkcode = $item->biblio->frameworkcode;
294             my $tagslib = C4::Biblio::GetMarcStructure( 1, $frameworkcode, { unsafe => 1 });
295             my ( $itemtag, $itemsubfield ) = C4::Biblio::GetMarcFromKohaField( "items.itemnumber" );
296
297             my @more_subfield_tags = map {
298                 (
299                          ref($_)
300                       && %$_
301                       && !$_->{kohafield}    # Get subfields that are not mapped
302                   )
303                   ? $_->{tagsubfield}
304                   : ()
305             } values %{ $tagslib->{$itemtag} };
306
307             my $more_subfields_xml = Koha::Item::Attributes->new(
308                 {
309                     map {
310                         exists $new_values->{$_} ? ( $_ => $new_values->{$_} )
311                           : exists $old_values->{$_}
312                           ? ( $_ => $old_values->{$_} )
313                           : ()
314                     } @more_subfield_tags
315                 }
316             )->to_marcxml($frameworkcode);
317
318             $new_values->{more_subfields_xml} = $more_subfields_xml;
319
320             delete $new_values->{$_} for @more_subfield_tags; # Clean the hash
321
322         }
323
324         if ( $modified ) {
325             my $itemlost_pre = $item->itemlost;
326             $item->set($new_values)->store({skip_record_index => 1});
327
328             LostItem(
329                 $item->itemnumber, 'batchmod', undef,
330                 { skip_record_index => 1 }
331             ) if $item->itemlost
332                   and not $itemlost_pre;
333
334             push @modified_itemnumbers, $item->itemnumber if $modified || $modified_holds_priority;
335             $modified_fields += $modified + $modified_holds_priority;
336         }
337
338         if ( $callback ) {
339             $callback->(++$i);
340         }
341     }
342
343     if (@modified_itemnumbers) {
344         my @biblionumbers = uniq(
345             Koha::Items->search( { itemnumber => \@modified_itemnumbers } )
346                        ->get_column('biblionumber'));
347
348         my $indexer = Koha::SearchEngine::Indexer->new(
349             { index => $Koha::SearchEngine::BIBLIOS_INDEX } );
350         $indexer->index_records( \@biblionumbers, 'specialUpdate',
351             "biblioserver", undef )
352           if @biblionumbers;
353     }
354
355     return ( { modified_itemnumbers => \@modified_itemnumbers, modified_fields => $modified_fields }, $self );
356 }
357
358 sub apply_regex { # FIXME Should be moved outside of Koha::Items
359     my ($params) = @_;
360     my $search   = $params->{search};
361     my $replace  = $params->{replace};
362     my $modifiers = $params->{modifiers} || q{};
363     my $value = $params->{value};
364
365     my @available_modifiers = qw( i g );
366     my $retained_modifiers  = q||;
367     for my $modifier ( split //, $modifiers ) {
368         $retained_modifiers .= $modifier
369           if grep { /$modifier/ } @available_modifiers;
370     }
371     if ( $retained_modifiers =~ m/^(ig|gi)$/ ) {
372         $value =~ s/$search/$replace/ig;
373     }
374     elsif ( $retained_modifiers eq 'i' ) {
375         $value =~ s/$search/$replace/i;
376     }
377     elsif ( $retained_modifiers eq 'g' ) {
378         $value =~ s/$search/$replace/g;
379     }
380     else {
381         $value =~ s/$search/$replace/;
382     }
383
384     return $value;
385 }
386
387
388 =head2 Internal methods
389
390 =head3 _type
391
392 =cut
393
394 sub _type {
395     return 'Item';
396 }
397
398 =head3 object_class
399
400 =cut
401
402 sub object_class {
403     return 'Koha::Item';
404 }
405
406 =head1 AUTHOR
407
408 Kyle M Hall <kyle@bywatersolutions.com>
409 Tomas Cohen Arazi <tomascohen@theke.io>
410 Martin Renvoize <martin.renvoize@ptfs-europe.com>
411
412 =cut
413
414 1;