Bug 19841: Make AddMember raise an exception if categorycode is invalid
[koha.git] / C4 / Members.pm
1 package C4::Members;
2
3 # Copyright 2000-2003 Katipo Communications
4 # Copyright 2010 BibLibre
5 # Parts Copyright 2010 Catalyst IT
6 #
7 # This file is part of Koha.
8 #
9 # Koha is free software; you can redistribute it and/or modify it
10 # under the terms of the GNU General Public License as published by
11 # the Free Software Foundation; either version 3 of the License, or
12 # (at your option) any later version.
13 #
14 # Koha is distributed in the hope that it will be useful, but
15 # WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with Koha; if not, see <http://www.gnu.org/licenses>.
21
22
23 use strict;
24 #use warnings; FIXME - Bug 2505
25 use C4::Context;
26 use String::Random qw( random_string );
27 use Scalar::Util qw( looks_like_number );
28 use Date::Calc qw/Today check_date Date_to_Days/;
29 use C4::Log; # logaction
30 use C4::Overdues;
31 use C4::Reserves;
32 use C4::Accounts;
33 use C4::Biblio;
34 use C4::Letters;
35 use C4::Members::Attributes qw(SearchIdMatchingAttribute UpdateBorrowerAttribute);
36 use C4::NewsChannels; #get slip news
37 use DateTime;
38 use Koha::Database;
39 use Koha::DateUtils;
40 use Text::Unaccent qw( unac_string );
41 use Koha::AuthUtils qw(hash_password);
42 use Koha::Database;
43 use Koha::Holds;
44 use Koha::List::Patron;
45 use Koha::Patrons;
46 use Koha::Patron::Categories;
47 use Koha::Schema;
48
49 our (@ISA,@EXPORT,@EXPORT_OK,$debug);
50
51 use Module::Load::Conditional qw( can_load );
52 if ( ! can_load( modules => { 'Koha::NorwegianPatronDB' => undef } ) ) {
53    $debug && warn "Unable to load Koha::NorwegianPatronDB";
54 }
55
56
57 BEGIN {
58     $debug = $ENV{DEBUG} || 0;
59     require Exporter;
60     @ISA = qw(Exporter);
61     #Get data
62     push @EXPORT, qw(
63
64         &GetPendingIssues
65         &GetAllIssues
66
67         &GetFirstValidEmailAddress
68         &GetNoticeEmailAddress
69
70         &GetMemberAccountRecords
71
72         &GetBorrowersToExpunge
73
74         &IssueSlip
75
76         GetOverduesForPatron
77     );
78
79     #Modify data
80     push @EXPORT, qw(
81         &ModMember
82         &changepassword
83     );
84
85     #Insert data
86     push @EXPORT, qw(
87         &AddMember
88     &AddMember_Auto
89         &AddMember_Opac
90     );
91
92     #Check data
93     push @EXPORT, qw(
94         &checkuniquemember
95         &checkuserpassword
96         &Check_Userid
97         &Generate_Userid
98         &fixup_cardnumber
99         &checkcardnumber
100     );
101 }
102
103 =head1 NAME
104
105 C4::Members - Perl Module containing convenience functions for member handling
106
107 =head1 SYNOPSIS
108
109 use C4::Members;
110
111 =head1 DESCRIPTION
112
113 This module contains routines for adding, modifying and deleting members/patrons/borrowers 
114
115 =head1 FUNCTIONS
116
117 =head2 patronflags
118
119  $flags = &patronflags($patron);
120
121 This function is not exported.
122
123 The following will be set where applicable:
124  $flags->{CHARGES}->{amount}        Amount of debt
125  $flags->{CHARGES}->{noissues}      Set if debt amount >$5.00 (or syspref noissuescharge)
126  $flags->{CHARGES}->{message}       Message -- deprecated
127
128  $flags->{CREDITS}->{amount}        Amount of credit
129  $flags->{CREDITS}->{message}       Message -- deprecated
130
131  $flags->{  GNA  }                  Patron has no valid address
132  $flags->{  GNA  }->{noissues}      Set for each GNA
133  $flags->{  GNA  }->{message}       "Borrower has no valid address" -- deprecated
134
135  $flags->{ LOST  }                  Patron's card reported lost
136  $flags->{ LOST  }->{noissues}      Set for each LOST
137  $flags->{ LOST  }->{message}       Message -- deprecated
138
139  $flags->{DBARRED}                  Set if patron debarred, no access
140  $flags->{DBARRED}->{noissues}      Set for each DBARRED
141  $flags->{DBARRED}->{message}       Message -- deprecated
142
143  $flags->{ NOTES }
144  $flags->{ NOTES }->{message}       The note itself.  NOT deprecated
145
146  $flags->{ ODUES }                  Set if patron has overdue books.
147  $flags->{ ODUES }->{message}       "Yes"  -- deprecated
148  $flags->{ ODUES }->{itemlist}      ref-to-array: list of overdue books
149  $flags->{ ODUES }->{itemlisttext}  Text list of overdue items -- deprecated
150
151  $flags->{WAITING}                  Set if any of patron's reserves are available
152  $flags->{WAITING}->{message}       Message -- deprecated
153  $flags->{WAITING}->{itemlist}      ref-to-array: list of available items
154
155 =over 
156
157 =item C<$flags-E<gt>{ODUES}-E<gt>{itemlist}> is a reference-to-array listing the
158 overdue items. Its elements are references-to-hash, each describing an
159 overdue item. The keys are selected fields from the issues, biblio,
160 biblioitems, and items tables of the Koha database.
161
162 =item C<$flags-E<gt>{ODUES}-E<gt>{itemlisttext}> is a string giving a text listing of
163 the overdue items, one per line.  Deprecated.
164
165 =item C<$flags-E<gt>{WAITING}-E<gt>{itemlist}> is a reference-to-array listing the
166 available items. Each element is a reference-to-hash whose keys are
167 fields from the reserves table of the Koha database.
168
169 =back
170
171 All the "message" fields that include language generated in this function are deprecated, 
172 because such strings belong properly in the display layer.
173
174 The "message" field that comes from the DB is OK.
175
176 =cut
177
178 # TODO: use {anonymous => hashes} instead of a dozen %flaginfo
179 # FIXME rename this function.
180 sub patronflags {
181     my %flags;
182     my ( $patroninformation) = @_;
183     my $dbh=C4::Context->dbh;
184     my ($balance, $owing) = GetMemberAccountBalance( $patroninformation->{'borrowernumber'});
185     if ( $owing > 0 ) {
186         my %flaginfo;
187         my $noissuescharge = C4::Context->preference("noissuescharge") || 5;
188         $flaginfo{'message'} = sprintf 'Patron owes %.02f', $owing;
189         $flaginfo{'amount'}  = sprintf "%.02f", $owing;
190         if ( $owing > $noissuescharge && !C4::Context->preference("AllowFineOverride") ) {
191             $flaginfo{'noissues'} = 1;
192         }
193         $flags{'CHARGES'} = \%flaginfo;
194     }
195     elsif ( $balance < 0 ) {
196         my %flaginfo;
197         $flaginfo{'message'} = sprintf 'Patron has credit of %.02f', -$balance;
198         $flaginfo{'amount'}  = sprintf "%.02f", $balance;
199         $flags{'CREDITS'} = \%flaginfo;
200     }
201
202     # Check the debt of the guarntees of this patron
203     my $no_issues_charge_guarantees = C4::Context->preference("NoIssuesChargeGuarantees");
204     $no_issues_charge_guarantees = undef unless looks_like_number( $no_issues_charge_guarantees );
205     if ( defined $no_issues_charge_guarantees ) {
206         my $p = Koha::Patrons->find( $patroninformation->{borrowernumber} );
207         my @guarantees = $p->guarantees();
208         my $guarantees_non_issues_charges;
209         foreach my $g ( @guarantees ) {
210             my ( $b, $n, $o ) = C4::Members::GetMemberAccountBalance( $g->id );
211             $guarantees_non_issues_charges += $n;
212         }
213
214         if ( $guarantees_non_issues_charges > $no_issues_charge_guarantees ) {
215             my %flaginfo;
216             $flaginfo{'message'} = sprintf 'patron guarantees owe %.02f', $guarantees_non_issues_charges;
217             $flaginfo{'amount'}  = $guarantees_non_issues_charges;
218             $flaginfo{'noissues'} = 1 unless C4::Context->preference("allowfineoverride");
219             $flags{'CHARGES_GUARANTEES'} = \%flaginfo;
220         }
221     }
222
223     if (   $patroninformation->{'gonenoaddress'}
224         && $patroninformation->{'gonenoaddress'} == 1 )
225     {
226         my %flaginfo;
227         $flaginfo{'message'}  = 'Borrower has no valid address.';
228         $flaginfo{'noissues'} = 1;
229         $flags{'GNA'}         = \%flaginfo;
230     }
231     if ( $patroninformation->{'lost'} && $patroninformation->{'lost'} == 1 ) {
232         my %flaginfo;
233         $flaginfo{'message'}  = 'Borrower\'s card reported lost.';
234         $flaginfo{'noissues'} = 1;
235         $flags{'LOST'}        = \%flaginfo;
236     }
237     if ( $patroninformation->{'debarred'} && check_date( split( /-/, $patroninformation->{'debarred'} ) ) ) {
238         if ( Date_to_Days(Date::Calc::Today) < Date_to_Days( split( /-/, $patroninformation->{'debarred'} ) ) ) {
239             my %flaginfo;
240             $flaginfo{'debarredcomment'} = $patroninformation->{'debarredcomment'};
241             $flaginfo{'message'}         = $patroninformation->{'debarredcomment'};
242             $flaginfo{'noissues'}        = 1;
243             $flaginfo{'dateend'}         = $patroninformation->{'debarred'};
244             $flags{'DBARRED'}           = \%flaginfo;
245         }
246     }
247     if (   $patroninformation->{'borrowernotes'}
248         && $patroninformation->{'borrowernotes'} )
249     {
250         my %flaginfo;
251         $flaginfo{'message'} = $patroninformation->{'borrowernotes'};
252         $flags{'NOTES'}      = \%flaginfo;
253     }
254     my ( $odues, $itemsoverdue ) = C4::Overdues::checkoverdues($patroninformation->{'borrowernumber'});
255     if ( $odues && $odues > 0 ) {
256         my %flaginfo;
257         $flaginfo{'message'}  = "Yes";
258         $flaginfo{'itemlist'} = $itemsoverdue;
259         foreach ( sort { $a->{'date_due'} cmp $b->{'date_due'} }
260             @$itemsoverdue )
261         {
262             $flaginfo{'itemlisttext'} .=
263               "$_->{'date_due'} $_->{'barcode'} $_->{'title'} \n";  # newline is display layer
264         }
265         $flags{'ODUES'} = \%flaginfo;
266     }
267
268     my $patron = Koha::Patrons->find( $patroninformation->{borrowernumber} );
269     my $waiting_holds = $patron->holds->search({ found => 'W' });
270     my $nowaiting = $waiting_holds->count;
271     if ( $nowaiting > 0 ) {
272         my %flaginfo;
273         $flaginfo{'message'}  = "Reserved items available";
274         $flaginfo{'itemlist'} = $waiting_holds->unblessed;
275         $flags{'WAITING'}     = \%flaginfo;
276     }
277     return ( \%flags );
278 }
279
280
281 =head2 ModMember
282
283   my $success = ModMember(borrowernumber => $borrowernumber,
284                                             [ field => value ]... );
285
286 Modify borrower's data.  All date fields should ALREADY be in ISO format.
287
288 return :
289 true on success, or false on failure
290
291 =cut
292
293 sub ModMember {
294     my (%data) = @_;
295
296     # trim whitespace from data which has some non-whitespace in it.
297     foreach my $field_name (keys(%data)) {
298         if ( defined $data{$field_name} && $data{$field_name} =~ /\S/ ) {
299             $data{$field_name} =~ s/^\s*|\s*$//g;
300         }
301     }
302
303     # test to know if you must update or not the borrower password
304     if (exists $data{password}) {
305         if ($data{password} eq '****' or $data{password} eq '') {
306             delete $data{password};
307         } else {
308             if ( C4::Context->preference('NorwegianPatronDBEnable') && C4::Context->preference('NorwegianPatronDBEnable') == 1 ) {
309                 # Update the hashed PIN in borrower_sync.hashed_pin, before Koha hashes it
310                 Koha::NorwegianPatronDB::NLUpdateHashedPIN( $data{'borrowernumber'}, $data{password} );
311             }
312             $data{password} = hash_password($data{password});
313         }
314     }
315
316     my $old_categorycode = Koha::Patrons->find( $data{borrowernumber} )->categorycode;
317
318     # get only the columns of a borrower
319     my $schema = Koha::Database->new()->schema;
320     my @columns = $schema->source('Borrower')->columns;
321     my $new_borrower = { map { join(' ', @columns) =~ /$_/ ? ( $_ => $data{$_} ) : () } keys(%data) };
322
323     $new_borrower->{dateofbirth}     ||= undef if exists $new_borrower->{dateofbirth};
324     $new_borrower->{dateenrolled}    ||= undef if exists $new_borrower->{dateenrolled};
325     $new_borrower->{dateexpiry}      ||= undef if exists $new_borrower->{dateexpiry};
326     $new_borrower->{debarred}        ||= undef if exists $new_borrower->{debarred};
327     $new_borrower->{sms_provider_id} ||= undef if exists $new_borrower->{sms_provider_id};
328     $new_borrower->{guarantorid}     ||= undef if exists $new_borrower->{guarantorid};
329
330     my $patron = Koha::Patrons->find( $new_borrower->{borrowernumber} );
331
332     delete $new_borrower->{userid} if exists $new_borrower->{userid} and not $new_borrower->{userid};
333
334     my $execute_success = $patron->store if $patron->set($new_borrower);
335
336     if ($execute_success) { # only proceed if the update was a success
337         # If the patron changes to a category with enrollment fee, we add a fee
338         if ( $data{categorycode} and $data{categorycode} ne $old_categorycode ) {
339             if ( C4::Context->preference('FeeOnChangePatronCategory') ) {
340                 $patron->add_enrolment_fee_if_needed;
341             }
342         }
343
344         # If NorwegianPatronDBEnable is enabled, we set syncstatus to something that a
345         # cronjob will use for syncing with NL
346         if ( C4::Context->preference('NorwegianPatronDBEnable') && C4::Context->preference('NorwegianPatronDBEnable') == 1 ) {
347             my $borrowersync = Koha::Database->new->schema->resultset('BorrowerSync')->find({
348                 'synctype'       => 'norwegianpatrondb',
349                 'borrowernumber' => $data{'borrowernumber'}
350             });
351             # Do not set to "edited" if syncstatus is "new". We need to sync as new before
352             # we can sync as changed. And the "new sync" will pick up all changes since
353             # the patron was created anyway.
354             if ( $borrowersync->syncstatus ne 'new' && $borrowersync->syncstatus ne 'delete' ) {
355                 $borrowersync->update( { 'syncstatus' => 'edited' } );
356             }
357             # Set the value of 'sync'
358             $borrowersync->update( { 'sync' => $data{'sync'} } );
359             # Try to do the live sync
360             Koha::NorwegianPatronDB::NLSync({ 'borrowernumber' => $data{'borrowernumber'} });
361         }
362
363         logaction("MEMBERS", "MODIFY", $data{'borrowernumber'}, "UPDATE (executed w/ arg: $data{'borrowernumber'})") if C4::Context->preference("BorrowersLog");
364     }
365     return $execute_success;
366 }
367
368 =head2 AddMember
369
370   $borrowernumber = &AddMember(%borrower);
371
372 insert new borrower into table
373
374 (%borrower keys are database columns. Database columns could be
375 different in different versions. Please look into database for correct
376 column names.)
377
378 Returns the borrowernumber upon success
379
380 Returns as undef upon any db error without further processing
381
382 =cut
383
384 #'
385 sub AddMember {
386     my (%data) = @_;
387     my $dbh = C4::Context->dbh;
388     my $schema = Koha::Database->new()->schema;
389
390     my $category = Koha::Patron::Categories->find( $data{categorycode} );
391     unless ($category) {
392         Koha::Exceptions::BadParameter->throw(
393             error => 'Invalid parameter passed',
394             parameter => 'categorycode'
395         );
396     }
397
398     # trim whitespace from data which has some non-whitespace in it.
399     foreach my $field_name (keys(%data)) {
400         if ( defined $data{$field_name} && $data{$field_name} =~ /\S/ ) {
401             $data{$field_name} =~ s/^\s*|\s*$//g;
402         }
403     }
404
405     # generate a proper login if none provided
406     $data{'userid'} = Generate_Userid( $data{'borrowernumber'}, $data{'firstname'}, $data{'surname'} )
407       if ( $data{'userid'} eq '' || !Check_Userid( $data{'userid'} ) );
408
409     # add expiration date if it isn't already there
410     $data{dateexpiry} ||= $category->get_expiry_date;
411
412     # add enrollment date if it isn't already there
413     unless ( $data{'dateenrolled'} ) {
414         $data{'dateenrolled'} = output_pref( { dt => dt_from_string, dateonly => 1, dateformat => 'iso' } );
415     }
416
417     if ( C4::Context->preference("autoMemberNum") ) {
418         if ( not exists $data{cardnumber} or not defined $data{cardnumber} or $data{cardnumber} eq '' ) {
419             $data{cardnumber} = fixup_cardnumber( $data{cardnumber} );
420         }
421     }
422
423     $data{'privacy'} =
424         $category->default_privacy() eq 'default' ? 1
425       : $category->default_privacy() eq 'never'   ? 2
426       : $category->default_privacy() eq 'forever' ? 0
427       :                                             undef;
428
429     $data{'privacy_guarantor_checkouts'} = 0 unless defined( $data{'privacy_guarantor_checkouts'} );
430
431     # Make a copy of the plain text password for later use
432     my $plain_text_password = $data{'password'};
433
434     # create a disabled account if no password provided
435     $data{'password'} = ($data{'password'})? hash_password($data{'password'}) : '!';
436
437     # we don't want invalid dates in the db (mysql has a bad habit of inserting 0000-00-00
438     $data{'dateofbirth'}     = undef if ( not $data{'dateofbirth'} );
439     $data{'debarred'}        = undef if ( not $data{'debarred'} );
440     $data{'sms_provider_id'} = undef if ( not $data{'sms_provider_id'} );
441     $data{'guarantorid'}     = undef if ( not $data{'guarantorid'} );
442
443     # get only the columns of Borrower
444     # FIXME Do we really need this check?
445     my @columns = $schema->source('Borrower')->columns;
446     my $new_member = { map { join(' ',@columns) =~ /$_/ ? ( $_ => $data{$_} )  : () } keys(%data) } ;
447
448     delete $new_member->{borrowernumber};
449
450     my $patron = Koha::Patron->new( $new_member )->store;
451     $data{borrowernumber} = $patron->borrowernumber;
452
453     # If NorwegianPatronDBEnable is enabled, we set syncstatus to something that a
454     # cronjob will use for syncing with NL
455     if ( exists $data{'borrowernumber'} && C4::Context->preference('NorwegianPatronDBEnable') && C4::Context->preference('NorwegianPatronDBEnable') == 1 ) {
456         Koha::Database->new->schema->resultset('BorrowerSync')->create({
457             'borrowernumber' => $data{'borrowernumber'},
458             'synctype'       => 'norwegianpatrondb',
459             'sync'           => 1,
460             'syncstatus'     => 'new',
461             'hashed_pin'     => Koha::NorwegianPatronDB::NLEncryptPIN( $plain_text_password ),
462         });
463     }
464
465     logaction("MEMBERS", "CREATE", $data{'borrowernumber'}, "") if C4::Context->preference("BorrowersLog");
466
467     $patron->add_enrolment_fee_if_needed;
468
469     return $data{borrowernumber};
470 }
471
472 =head2 Check_Userid
473
474     my $uniqueness = Check_Userid($userid,$borrowernumber);
475
476     $borrowernumber is optional (i.e. it can contain a blank value). If $userid is passed with a blank $borrowernumber variable, the database will be checked for all instances of that userid (i.e. userid=? AND borrowernumber != '').
477
478     If $borrowernumber is provided, the database will be checked for every instance of that userid coupled with a different borrower(number) than the one provided.
479
480     return :
481         0 for not unique (i.e. this $userid already exists)
482         1 for unique (i.e. this $userid does not exist, or this $userid/$borrowernumber combination already exists)
483
484 =cut
485
486 sub Check_Userid {
487     my ( $uid, $borrowernumber ) = @_;
488
489     return 0 unless ($uid); # userid is a unique column, we should assume NULL is not unique
490
491     return 0 if ( $uid eq C4::Context->config('user') );
492
493     my $rs = Koha::Database->new()->schema()->resultset('Borrower');
494
495     my $params;
496     $params->{userid} = $uid;
497     $params->{borrowernumber} = { '!=' => $borrowernumber } if ($borrowernumber);
498
499     my $count = $rs->count( $params );
500
501     return $count ? 0 : 1;
502 }
503
504 =head2 Generate_Userid
505
506     my $newuid = Generate_Userid($borrowernumber, $firstname, $surname);
507
508     Generate a userid using the $surname and the $firstname (if there is a value in $firstname).
509
510     $borrowernumber is optional (i.e. it can contain a blank value). A value is passed when generating a new userid for an existing borrower. When a new userid is created for a new borrower, a blank value is passed to this sub.
511
512     return :
513         new userid ($firstname.$surname if there is a $firstname, or $surname if there is no value in $firstname) plus offset (0 if the $newuid is unique, or a higher numeric value if Check_Userid finds an existing match for the $newuid in the database).
514
515 =cut
516
517 sub Generate_Userid {
518   my ($borrowernumber, $firstname, $surname) = @_;
519   my $newuid;
520   my $offset = 0;
521   #The script will "do" the following code and increment the $offset until Check_Userid = 1 (i.e. until $newuid comes back as unique)
522   do {
523     $firstname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
524     $surname =~ s/[[:digit:][:space:][:blank:][:punct:][:cntrl:]]//g;
525     $newuid = lc(($firstname)? "$firstname.$surname" : $surname);
526     $newuid = unac_string('utf-8',$newuid);
527     $newuid .= $offset unless $offset == 0;
528     $offset++;
529
530    } while (!Check_Userid($newuid,$borrowernumber));
531
532    return $newuid;
533 }
534
535 =head2 fixup_cardnumber
536
537 Warning: The caller is responsible for locking the members table in write
538 mode, to avoid database corruption.
539
540 =cut
541
542 use vars qw( @weightings );
543 my @weightings = ( 8, 4, 6, 3, 5, 2, 1 );
544
545 sub fixup_cardnumber {
546     my ($cardnumber) = @_;
547     my $autonumber_members = C4::Context->boolean_preference('autoMemberNum') || 0;
548
549     # Find out whether member numbers should be generated
550     # automatically. Should be either "1" or something else.
551     # Defaults to "0", which is interpreted as "no".
552
553     #     if ($cardnumber !~ /\S/ && $autonumber_members) {
554     ($autonumber_members) or return $cardnumber;
555     my $checkdigit = C4::Context->preference('checkdigit');
556     my $dbh = C4::Context->dbh;
557     if ( $checkdigit and $checkdigit eq 'katipo' ) {
558
559         # if checkdigit is selected, calculate katipo-style cardnumber.
560         # otherwise, just use the max()
561         # purpose: generate checksum'd member numbers.
562         # We'll assume we just got the max value of digits 2-8 of member #'s
563         # from the database and our job is to increment that by one,
564         # determine the 1st and 9th digits and return the full string.
565         my $sth = $dbh->prepare(
566             "select max(substring(borrowers.cardnumber,2,7)) as new_num from borrowers"
567         );
568         $sth->execute;
569         my $data = $sth->fetchrow_hashref;
570         $cardnumber = $data->{new_num};
571         if ( !$cardnumber ) {    # If DB has no values,
572             $cardnumber = 1000000;    # start at 1000000
573         } else {
574             $cardnumber += 1;
575         }
576
577         my $sum = 0;
578         for ( my $i = 0 ; $i < 8 ; $i += 1 ) {
579             # read weightings, left to right, 1 char at a time
580             my $temp1 = $weightings[$i];
581
582             # sequence left to right, 1 char at a time
583             my $temp2 = substr( $cardnumber, $i, 1 );
584
585             # mult each char 1-7 by its corresponding weighting
586             $sum += $temp1 * $temp2;
587         }
588
589         my $rem = ( $sum % 11 );
590         $rem = 'X' if $rem == 10;
591
592         return "V$cardnumber$rem";
593      } else {
594
595         my $sth = $dbh->prepare(
596             'SELECT MAX( CAST( cardnumber AS SIGNED ) ) FROM borrowers WHERE cardnumber REGEXP "^-?[0-9]+$"'
597         );
598         $sth->execute;
599         my ($result) = $sth->fetchrow;
600         return $result + 1;
601     }
602     return $cardnumber;     # just here as a fallback/reminder 
603 }
604
605 =head2 GetPendingIssues
606
607   my $issues = &GetPendingIssues(@borrowernumber);
608
609 Looks up what the patron with the given borrowernumber has borrowed.
610
611 C<&GetPendingIssues> returns a
612 reference-to-array where each element is a reference-to-hash; the
613 keys are the fields from the C<issues>, C<biblio>, and C<items> tables.
614 The keys include C<biblioitems> fields.
615
616 =cut
617
618 sub GetPendingIssues {
619     my @borrowernumbers = @_;
620
621     unless (@borrowernumbers ) { # return a ref_to_array
622         return \@borrowernumbers; # to not cause surprise to caller
623     }
624
625     # Borrowers part of the query
626     my $bquery = '';
627     for (my $i = 0; $i < @borrowernumbers; $i++) {
628         $bquery .= ' issues.borrowernumber = ?';
629         if ($i < $#borrowernumbers ) {
630             $bquery .= ' OR';
631         }
632     }
633
634     # FIXME: namespace collision: each table has "timestamp" fields.  Which one is "timestamp" ?
635     # FIXME: circ/ciculation.pl tries to sort by timestamp!
636     # FIXME: namespace collision: other collisions possible.
637     # FIXME: most of this data isn't really being used by callers.
638     my $query =
639    "SELECT issues.*,
640             items.*,
641            biblio.*,
642            biblioitems.volume,
643            biblioitems.number,
644            biblioitems.itemtype,
645            biblioitems.isbn,
646            biblioitems.issn,
647            biblioitems.publicationyear,
648            biblioitems.publishercode,
649            biblioitems.volumedate,
650            biblioitems.volumedesc,
651            biblioitems.lccn,
652            biblioitems.url,
653            borrowers.firstname,
654            borrowers.surname,
655            borrowers.cardnumber,
656            issues.timestamp AS timestamp,
657            issues.renewals  AS renewals,
658            issues.borrowernumber AS borrowernumber,
659             items.renewals  AS totalrenewals
660     FROM   issues
661     LEFT JOIN items       ON items.itemnumber       =      issues.itemnumber
662     LEFT JOIN biblio      ON items.biblionumber     =      biblio.biblionumber
663     LEFT JOIN biblioitems ON items.biblioitemnumber = biblioitems.biblioitemnumber
664     LEFT JOIN borrowers ON issues.borrowernumber = borrowers.borrowernumber
665     WHERE
666       $bquery
667     ORDER BY issues.issuedate"
668     ;
669
670     my $sth = C4::Context->dbh->prepare($query);
671     $sth->execute(@borrowernumbers);
672     my $data = $sth->fetchall_arrayref({});
673     my $today = dt_from_string;
674     foreach (@{$data}) {
675         if ($_->{issuedate}) {
676             $_->{issuedate} = dt_from_string($_->{issuedate}, 'sql');
677         }
678         $_->{date_due_sql} = $_->{date_due};
679         # FIXME no need to have this value
680         $_->{date_due} or next;
681         $_->{date_due_sql} = $_->{date_due};
682         # FIXME no need to have this value
683         $_->{date_due} = dt_from_string($_->{date_due}, 'sql');
684         if ( DateTime->compare($_->{date_due}, $today) == -1 ) {
685             $_->{overdue} = 1;
686         }
687     }
688     return $data;
689 }
690
691 =head2 GetAllIssues
692
693   $issues = &GetAllIssues($borrowernumber, $sortkey, $limit);
694
695 Looks up what the patron with the given borrowernumber has borrowed,
696 and sorts the results.
697
698 C<$sortkey> is the name of a field on which to sort the results. This
699 should be the name of a field in the C<issues>, C<biblio>,
700 C<biblioitems>, or C<items> table in the Koha database.
701
702 C<$limit> is the maximum number of results to return.
703
704 C<&GetAllIssues> an arrayref, C<$issues>, of hashrefs, the keys of which
705 are the fields from the C<issues>, C<biblio>, C<biblioitems>, and
706 C<items> tables of the Koha database.
707
708 =cut
709
710 #'
711 sub GetAllIssues {
712     my ( $borrowernumber, $order, $limit ) = @_;
713
714     return unless $borrowernumber;
715     $order = 'date_due desc' unless $order;
716
717     my $dbh = C4::Context->dbh;
718     my $query =
719 'SELECT *, issues.timestamp as issuestimestamp, issues.renewals AS renewals,items.renewals AS totalrenewals,items.timestamp AS itemstimestamp
720   FROM issues 
721   LEFT JOIN items on items.itemnumber=issues.itemnumber
722   LEFT JOIN biblio ON items.biblionumber=biblio.biblionumber
723   LEFT JOIN biblioitems ON items.biblioitemnumber=biblioitems.biblioitemnumber
724   WHERE borrowernumber=? 
725   UNION ALL
726   SELECT *, old_issues.timestamp as issuestimestamp, old_issues.renewals AS renewals,items.renewals AS totalrenewals,items.timestamp AS itemstimestamp 
727   FROM old_issues 
728   LEFT JOIN items on items.itemnumber=old_issues.itemnumber
729   LEFT JOIN biblio ON items.biblionumber=biblio.biblionumber
730   LEFT JOIN biblioitems ON items.biblioitemnumber=biblioitems.biblioitemnumber
731   WHERE borrowernumber=? AND old_issues.itemnumber IS NOT NULL
732   order by ' . $order;
733     if ($limit) {
734         $query .= " limit $limit";
735     }
736
737     my $sth = $dbh->prepare($query);
738     $sth->execute( $borrowernumber, $borrowernumber );
739     return $sth->fetchall_arrayref( {} );
740 }
741
742
743 =head2 GetMemberAccountRecords
744
745   ($total, $acctlines, $count) = &GetMemberAccountRecords($borrowernumber);
746
747 Looks up accounting data for the patron with the given borrowernumber.
748
749 C<&GetMemberAccountRecords> returns a three-element array. C<$acctlines> is a
750 reference-to-array, where each element is a reference-to-hash; the
751 keys are the fields of the C<accountlines> table in the Koha database.
752 C<$count> is the number of elements in C<$acctlines>. C<$total> is the
753 total amount outstanding for all of the account lines.
754
755 =cut
756
757 sub GetMemberAccountRecords {
758     my ($borrowernumber) = @_;
759     my $dbh = C4::Context->dbh;
760     my @acctlines;
761     my $numlines = 0;
762     my $strsth      = qq(
763                         SELECT * 
764                         FROM accountlines 
765                         WHERE borrowernumber=?);
766     $strsth.=" ORDER BY accountlines_id desc";
767     my $sth= $dbh->prepare( $strsth );
768     $sth->execute( $borrowernumber );
769
770     my $total = 0;
771     while ( my $data = $sth->fetchrow_hashref ) {
772         if ( $data->{itemnumber} ) {
773             my $item = Koha::Items->find( $data->{itemnumber} );
774             my $biblio = $item->biblio;
775             $data->{biblionumber} = $biblio->biblionumber;
776             $data->{title}        = $biblio->title;
777         }
778         $acctlines[$numlines] = $data;
779         $numlines++;
780         $total += sprintf "%.0f", 1000*$data->{amountoutstanding}; # convert float to integer to avoid round-off errors
781     }
782     $total /= 1000;
783     return ( $total, \@acctlines,$numlines);
784 }
785
786 =head2 GetMemberAccountBalance
787
788   ($total_balance, $non_issue_balance, $other_charges) = &GetMemberAccountBalance($borrowernumber);
789
790 Calculates amount immediately owing by the patron - non-issue charges.
791 Based on GetMemberAccountRecords.
792 Charges exempt from non-issue are:
793 * Res (reserves)
794 * Rent (rental) if RentalsInNoissuesCharge syspref is set to false
795 * Manual invoices if ManInvInNoissuesCharge syspref is set to false
796
797 =cut
798
799 sub GetMemberAccountBalance {
800     my ($borrowernumber) = @_;
801
802     my $ACCOUNT_TYPE_LENGTH = 5; # this is plain ridiculous...
803
804     my @not_fines;
805     push @not_fines, 'Res' unless C4::Context->preference('HoldsInNoissuesCharge');
806     push @not_fines, 'Rent' unless C4::Context->preference('RentalsInNoissuesCharge');
807     unless ( C4::Context->preference('ManInvInNoissuesCharge') ) {
808         my $dbh = C4::Context->dbh;
809         my $man_inv_types = $dbh->selectcol_arrayref(qq{SELECT authorised_value FROM authorised_values WHERE category = 'MANUAL_INV'});
810         push @not_fines, map substr($_, 0, $ACCOUNT_TYPE_LENGTH), @$man_inv_types;
811     }
812     my %not_fine = map {$_ => 1} @not_fines;
813
814     my ($total, $acctlines) = GetMemberAccountRecords($borrowernumber);
815     my $other_charges = 0;
816     foreach (@$acctlines) {
817         $other_charges += $_->{amountoutstanding} if $not_fine{ substr($_->{accounttype}, 0, $ACCOUNT_TYPE_LENGTH) };
818     }
819
820     return ( $total, $total - $other_charges, $other_charges);
821 }
822
823 sub checkcardnumber {
824     my ( $cardnumber, $borrowernumber ) = @_;
825
826     # If cardnumber is null, we assume they're allowed.
827     return 0 unless defined $cardnumber;
828
829     my $dbh = C4::Context->dbh;
830     my $query = "SELECT * FROM borrowers WHERE cardnumber=?";
831     $query .= " AND borrowernumber <> ?" if ($borrowernumber);
832     my $sth = $dbh->prepare($query);
833     $sth->execute(
834         $cardnumber,
835         ( $borrowernumber ? $borrowernumber : () )
836     );
837
838     return 1 if $sth->fetchrow_hashref;
839
840     my ( $min_length, $max_length ) = get_cardnumber_length();
841     return 2
842         if length $cardnumber > $max_length
843         or length $cardnumber < $min_length;
844
845     return 0;
846 }
847
848 =head2 get_cardnumber_length
849
850     my ($min, $max) = C4::Members::get_cardnumber_length()
851
852 Returns the minimum and maximum length for patron cardnumbers as
853 determined by the CardnumberLength system preference, the
854 BorrowerMandatoryField system preference, and the width of the
855 database column.
856
857 =cut
858
859 sub get_cardnumber_length {
860     my $borrower = Koha::Schema->resultset('Borrower');
861     my $field_size = $borrower->result_source->column_info('cardnumber')->{size};
862     my ( $min, $max ) = ( 0, $field_size ); # borrowers.cardnumber is a nullable varchar(20)
863     $min = 1 if C4::Context->preference('BorrowerMandatoryField') =~ /cardnumber/;
864     if ( my $cardnumber_length = C4::Context->preference('CardnumberLength') ) {
865         # Is integer and length match
866         if ( $cardnumber_length =~ m|^\d+$| ) {
867             $min = $max = $cardnumber_length
868                 if $cardnumber_length >= $min
869                     and $cardnumber_length <= $max;
870         }
871         # Else assuming it is a range
872         elsif ( $cardnumber_length =~ m|(\d*),(\d*)| ) {
873             $min = $1 if $1 and $min < $1;
874             $max = $2 if $2 and $max > $2;
875         }
876
877     }
878     $min = $max if $min > $max;
879     return ( $min, $max );
880 }
881
882 =head2 GetFirstValidEmailAddress
883
884   $email = GetFirstValidEmailAddress($borrowernumber);
885
886 Return the first valid email address for a borrower, given the borrowernumber.  For now, the order 
887 is defined as email, emailpro, B_email.  Returns the empty string if the borrower has no email 
888 addresses.
889
890 =cut
891
892 sub GetFirstValidEmailAddress {
893     my $borrowernumber = shift;
894
895     my $borrower = Koha::Patrons->find( $borrowernumber );
896
897     return $borrower->first_valid_email_address();
898 }
899
900 =head2 GetNoticeEmailAddress
901
902   $email = GetNoticeEmailAddress($borrowernumber);
903
904 Return the email address of borrower used for notices, given the borrowernumber.
905 Returns the empty string if no email address.
906
907 =cut
908
909 sub GetNoticeEmailAddress {
910     my $borrowernumber = shift;
911
912     my $which_address = C4::Context->preference("AutoEmailPrimaryAddress");
913     # if syspref is set to 'first valid' (value == OFF), look up email address
914     if ( $which_address eq 'OFF' ) {
915         return GetFirstValidEmailAddress($borrowernumber);
916     }
917     # specified email address field
918     my $dbh = C4::Context->dbh;
919     my $sth = $dbh->prepare( qq{
920         SELECT $which_address AS primaryemail
921         FROM borrowers
922         WHERE borrowernumber=?
923     } );
924     $sth->execute($borrowernumber);
925     my $data = $sth->fetchrow_hashref;
926     return $data->{'primaryemail'} || '';
927 }
928
929 =head2 GetBorrowersToExpunge
930
931   $borrowers = &GetBorrowersToExpunge(
932       not_borrowed_since => $not_borrowed_since,
933       expired_before       => $expired_before,
934       category_code        => $category_code,
935       patron_list_id       => $patron_list_id,
936       branchcode           => $branchcode
937   );
938
939   This function get all borrowers based on the given criteria.
940
941 =cut
942
943 sub GetBorrowersToExpunge {
944
945     my $params = shift;
946     my $filterdate       = $params->{'not_borrowed_since'};
947     my $filterexpiry     = $params->{'expired_before'};
948     my $filterlastseen   = $params->{'last_seen'};
949     my $filtercategory   = $params->{'category_code'};
950     my $filterbranch     = $params->{'branchcode'} ||
951                         ((C4::Context->preference('IndependentBranches')
952                              && C4::Context->userenv 
953                              && !C4::Context->IsSuperLibrarian()
954                              && C4::Context->userenv->{branch})
955                          ? C4::Context->userenv->{branch}
956                          : "");  
957     my $filterpatronlist = $params->{'patron_list_id'};
958
959     my $dbh   = C4::Context->dbh;
960     my $query = q|
961         SELECT borrowers.borrowernumber,
962                MAX(old_issues.timestamp) AS latestissue,
963                MAX(issues.timestamp) AS currentissue
964         FROM   borrowers
965         JOIN   categories USING (categorycode)
966         LEFT JOIN (
967             SELECT guarantorid
968             FROM borrowers
969             WHERE guarantorid IS NOT NULL
970                 AND guarantorid <> 0
971         ) as tmp ON borrowers.borrowernumber=tmp.guarantorid
972         LEFT JOIN old_issues USING (borrowernumber)
973         LEFT JOIN issues USING (borrowernumber)|;
974     if ( $filterpatronlist  ){
975         $query .= q| LEFT JOIN patron_list_patrons USING (borrowernumber)|;
976     }
977     $query .= q| WHERE  category_type <> 'S'
978         AND tmp.guarantorid IS NULL
979    |;
980     my @query_params;
981     if ( $filterbranch && $filterbranch ne "" ) {
982         $query.= " AND borrowers.branchcode = ? ";
983         push( @query_params, $filterbranch );
984     }
985     if ( $filterexpiry ) {
986         $query .= " AND dateexpiry < ? ";
987         push( @query_params, $filterexpiry );
988     }
989     if ( $filterlastseen ) {
990         $query .= ' AND lastseen < ? ';
991         push @query_params, $filterlastseen;
992     }
993     if ( $filtercategory ) {
994         $query .= " AND categorycode = ? ";
995         push( @query_params, $filtercategory );
996     }
997     if ( $filterpatronlist ){
998         $query.=" AND patron_list_id = ? ";
999         push( @query_params, $filterpatronlist );
1000     }
1001     $query.=" GROUP BY borrowers.borrowernumber HAVING currentissue IS NULL ";
1002     if ( $filterdate ) {
1003         $query.=" AND ( latestissue < ? OR latestissue IS NULL ) ";
1004         push @query_params,$filterdate;
1005     }
1006     warn $query if $debug;
1007
1008     my $sth = $dbh->prepare($query);
1009     if (scalar(@query_params)>0){  
1010         $sth->execute(@query_params);
1011     }
1012     else {
1013         $sth->execute;
1014     }
1015     
1016     my @results;
1017     while ( my $data = $sth->fetchrow_hashref ) {
1018         push @results, $data;
1019     }
1020     return \@results;
1021 }
1022
1023 =head2 IssueSlip
1024
1025   IssueSlip($branchcode, $borrowernumber, $quickslip)
1026
1027   Returns letter hash ( see C4::Letters::GetPreparedLetter )
1028
1029   $quickslip is boolean, to indicate whether we want a quick slip
1030
1031   IssueSlip populates ISSUESLIP and ISSUEQSLIP, and will make the following expansions:
1032
1033   Both slips:
1034
1035       <<branches.*>>
1036       <<borrowers.*>>
1037
1038   ISSUESLIP:
1039
1040       <checkedout>
1041          <<biblio.*>>
1042          <<items.*>>
1043          <<biblioitems.*>>
1044          <<issues.*>>
1045       </checkedout>
1046
1047       <overdue>
1048          <<biblio.*>>
1049          <<items.*>>
1050          <<biblioitems.*>>
1051          <<issues.*>>
1052       </overdue>
1053
1054       <news>
1055          <<opac_news.*>>
1056       </news>
1057
1058   ISSUEQSLIP:
1059
1060       <checkedout>
1061          <<biblio.*>>
1062          <<items.*>>
1063          <<biblioitems.*>>
1064          <<issues.*>>
1065       </checkedout>
1066
1067   NOTE: Not all table fields are available, pleasee see GetPendingIssues for a list of available fields.
1068
1069 =cut
1070
1071 sub IssueSlip {
1072     my ($branch, $borrowernumber, $quickslip) = @_;
1073
1074     # FIXME Check callers before removing this statement
1075     #return unless $borrowernumber;
1076
1077     my $patron = Koha::Patrons->find( $borrowernumber );
1078     return unless $patron;
1079
1080     my @issues = @{ GetPendingIssues($borrowernumber) };
1081
1082     for my $issue (@issues) {
1083         $issue->{date_due} = $issue->{date_due_sql};
1084         if ($quickslip) {
1085             my $today = output_pref({ dt => dt_from_string, dateformat => 'iso', dateonly => 1 });
1086             if ( substr( $issue->{issuedate}, 0, 10 ) eq $today
1087                 or substr( $issue->{lastreneweddate}, 0, 10 ) eq $today ) {
1088                   $issue->{now} = 1;
1089             };
1090         }
1091     }
1092
1093     # Sort on timestamp then on issuedate then on issue_id
1094     # useful for tests and could be if modified in a batch
1095     @issues = sort {
1096             $b->{timestamp} <=> $a->{timestamp}
1097          or $b->{issuedate} <=> $a->{issuedate}
1098          or $b->{issue_id}  <=> $a->{issue_id}
1099     } @issues;
1100
1101     my ($letter_code, %repeat, %loops);
1102     if ( $quickslip ) {
1103         $letter_code = 'ISSUEQSLIP';
1104         my @checkouts = map {
1105                 'biblio'       => $_,
1106                 'items'        => $_,
1107                 'biblioitems'  => $_,
1108                 'issues'       => $_,
1109             }, grep { $_->{'now'} } @issues;
1110         %repeat =  (
1111             checkedout => \@checkouts, # History syntax
1112         );
1113         %loops = (
1114             issues => [ map { $_->{issues}{itemnumber} } @checkouts ], # TT syntax
1115         );
1116     }
1117     else {
1118         my @checkouts = map {
1119             'biblio'        => $_,
1120               'items'       => $_,
1121               'biblioitems' => $_,
1122               'issues'      => $_,
1123         }, grep { !$_->{'overdue'} } @issues;
1124         my @overdues = map {
1125             'biblio'        => $_,
1126               'items'       => $_,
1127               'biblioitems' => $_,
1128               'issues'      => $_,
1129         }, grep { $_->{'overdue'} } @issues;
1130         my $news = GetNewsToDisplay( "slip", $branch );
1131         my @news = map {
1132             $_->{'timestamp'} = $_->{'newdate'};
1133             { opac_news => $_ }
1134         } @$news;
1135         $letter_code = 'ISSUESLIP';
1136         %repeat      = (
1137             checkedout => \@checkouts,
1138             overdue    => \@overdues,
1139             news       => \@news,
1140         );
1141         %loops = (
1142             issues => [ map { $_->{issues}{itemnumber} } @checkouts ],
1143             overdues   => [ map { $_->{issues}{itemnumber} } @overdues ],
1144             opac_news => [ map { $_->{opac_news}{idnew} } @news ],
1145         );
1146     }
1147
1148     return  C4::Letters::GetPreparedLetter (
1149         module => 'circulation',
1150         letter_code => $letter_code,
1151         branchcode => $branch,
1152         lang => $patron->lang,
1153         tables => {
1154             'branches'    => $branch,
1155             'borrowers'   => $borrowernumber,
1156         },
1157         repeat => \%repeat,
1158         loops => \%loops,
1159     );
1160 }
1161
1162 =head2 AddMember_Auto
1163
1164 =cut
1165
1166 sub AddMember_Auto {
1167     my ( %borrower ) = @_;
1168
1169     $borrower{'cardnumber'} ||= fixup_cardnumber();
1170
1171     $borrower{'borrowernumber'} = AddMember(%borrower);
1172
1173     return ( %borrower );
1174 }
1175
1176 =head2 AddMember_Opac
1177
1178 =cut
1179
1180 sub AddMember_Opac {
1181     my ( %borrower ) = @_;
1182
1183     $borrower{'categorycode'} //= C4::Context->preference('PatronSelfRegistrationDefaultCategory');
1184     if (not defined $borrower{'password'}){
1185         my $sr = new String::Random;
1186         $sr->{'A'} = [ 'A'..'Z', 'a'..'z' ];
1187         my $password = $sr->randpattern("AAAAAAAAAA");
1188         $borrower{'password'} = $password;
1189     }
1190
1191     %borrower = AddMember_Auto(%borrower);
1192
1193     return ( $borrower{'borrowernumber'}, $borrower{'password'} );
1194 }
1195
1196 =head2 DeleteExpiredOpacRegistrations
1197
1198     Delete accounts that haven't been upgraded from the 'temporary' category
1199     Returns the number of removed patrons
1200
1201 =cut
1202
1203 sub DeleteExpiredOpacRegistrations {
1204
1205     my $delay = C4::Context->preference('PatronSelfRegistrationExpireTemporaryAccountsDelay');
1206     my $category_code = C4::Context->preference('PatronSelfRegistrationDefaultCategory');
1207
1208     return 0 if not $category_code or not defined $delay or $delay eq q||;
1209
1210     my $query = qq|
1211 SELECT borrowernumber
1212 FROM borrowers
1213 WHERE categorycode = ? AND DATEDIFF( NOW(), dateenrolled ) > ? |;
1214
1215     my $dbh = C4::Context->dbh;
1216     my $sth = $dbh->prepare($query);
1217     $sth->execute( $category_code, $delay );
1218     my $cnt=0;
1219     while ( my ($borrowernumber) = $sth->fetchrow_array() ) {
1220         Koha::Patrons->find($borrowernumber)->delete;
1221         $cnt++;
1222     }
1223     return $cnt;
1224 }
1225
1226 =head2 DeleteUnverifiedOpacRegistrations
1227
1228     Delete all unverified self registrations in borrower_modifications,
1229     older than the specified number of days.
1230
1231 =cut
1232
1233 sub DeleteUnverifiedOpacRegistrations {
1234     my ( $days ) = @_;
1235     my $dbh = C4::Context->dbh;
1236     my $sql=qq|
1237 DELETE FROM borrower_modifications
1238 WHERE borrowernumber = 0 AND DATEDIFF( NOW(), timestamp ) > ?|;
1239     my $cnt=$dbh->do($sql, undef, ($days) );
1240     return $cnt eq '0E0'? 0: $cnt;
1241 }
1242
1243 sub GetOverduesForPatron {
1244     my ( $borrowernumber ) = @_;
1245
1246     my $sql = "
1247         SELECT *
1248         FROM issues, items, biblio, biblioitems
1249         WHERE items.itemnumber=issues.itemnumber
1250           AND biblio.biblionumber   = items.biblionumber
1251           AND biblio.biblionumber   = biblioitems.biblionumber
1252           AND issues.borrowernumber = ?
1253           AND date_due < NOW()
1254     ";
1255
1256     my $sth = C4::Context->dbh->prepare( $sql );
1257     $sth->execute( $borrowernumber );
1258
1259     return $sth->fetchall_arrayref({});
1260 }
1261
1262 END { }    # module clean-up code here (global destructor)
1263
1264 1;
1265
1266 __END__
1267
1268 =head1 AUTHOR
1269
1270 Koha Team
1271
1272 =cut