Bug 18314: Account lockout
[koha-equinox.git] / Koha / Patron.pm
1 package Koha::Patron;
2
3 # Copyright ByWater Solutions 2014
4 # Copyright PTFS Europe 2016
5 #
6 # This file is part of Koha.
7 #
8 # Koha is free software; you can redistribute it and/or modify it under the
9 # terms of the GNU General Public License as published by the Free Software
10 # Foundation; either version 3 of the License, or (at your option) any later
11 # version.
12 #
13 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
14 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
15 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License along
18 # with Koha; if not, write to the Free Software Foundation, Inc.,
19 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
20
21 use Modern::Perl;
22
23 use Carp;
24
25 use C4::Context;
26 use C4::Log;
27 use Koha::Checkouts;
28 use Koha::Database;
29 use Koha::DateUtils;
30 use Koha::Holds;
31 use Koha::Old::Checkouts;
32 use Koha::Patron::Categories;
33 use Koha::Patron::HouseboundProfile;
34 use Koha::Patron::HouseboundRole;
35 use Koha::Patron::Images;
36 use Koha::Patrons;
37 use Koha::Virtualshelves;
38 use Koha::Club::Enrollments;
39
40 use base qw(Koha::Object);
41
42 =head1 NAME
43
44 Koha::Patron - Koha Patron Object class
45
46 =head1 API
47
48 =head2 Class Methods
49
50 =cut
51
52 =head3 delete
53
54 $patron->delete
55
56 Delete patron's holds, lists and finally the patron.
57
58 Lists owned by the borrower are deleted, but entries from the borrower to
59 other lists are kept.
60
61 =cut
62
63 sub delete {
64     my ($self) = @_;
65
66     my $deleted;
67     $self->_result->result_source->schema->txn_do(
68         sub {
69             # Delete Patron's holds
70             $self->holds->delete;
71
72             # Delete all lists and all shares of this borrower
73             # Consistent with the approach Koha uses on deleting individual lists
74             # Note that entries in virtualshelfcontents added by this borrower to
75             # lists of others will be handled by a table constraint: the borrower
76             # is set to NULL in those entries.
77             # NOTE:
78             # We could handle the above deletes via a constraint too.
79             # But a new BZ report 11889 has been opened to discuss another approach.
80             # Instead of deleting we could also disown lists (based on a pref).
81             # In that way we could save shared and public lists.
82             # The current table constraints support that idea now.
83             # This pref should then govern the results of other routines/methods such as
84             # Koha::Virtualshelf->new->delete too.
85             # FIXME Could be $patron->get_lists
86             $_->delete for Koha::Virtualshelves->search( { owner => $self->borrowernumber } );
87
88             $deleted = $self->SUPER::delete;
89
90             logaction( "MEMBERS", "DELETE", $self->borrowernumber, "" ) if C4::Context->preference("BorrowersLog");
91         }
92     );
93     return $deleted;
94 }
95
96
97 =head3 category
98
99 my $patron_category = $patron->category
100
101 Return the patron category for this patron
102
103 =cut
104
105 sub category {
106     my ( $self ) = @_;
107     return Koha::Patron::Category->_new_from_dbic( $self->_result->categorycode );
108 }
109
110 =head3 guarantor
111
112 Returns a Koha::Patron object for this patron's guarantor
113
114 =cut
115
116 sub guarantor {
117     my ( $self ) = @_;
118
119     return unless $self->guarantorid();
120
121     return Koha::Patrons->find( $self->guarantorid() );
122 }
123
124 sub image {
125     my ( $self ) = @_;
126
127     return Koha::Patron::Images->find( $self->borrowernumber );
128 }
129
130 sub library {
131     my ( $self ) = @_;
132     return Koha::Library->_new_from_dbic($self->_result->branchcode);
133 }
134
135 =head3 guarantees
136
137 Returns the guarantees (list of Koha::Patron) of this patron
138
139 =cut
140
141 sub guarantees {
142     my ( $self ) = @_;
143
144     return Koha::Patrons->search( { guarantorid => $self->borrowernumber } );
145 }
146
147 =head3 housebound_profile
148
149 Returns the HouseboundProfile associated with this patron.
150
151 =cut
152
153 sub housebound_profile {
154     my ( $self ) = @_;
155     my $profile = $self->_result->housebound_profile;
156     return Koha::Patron::HouseboundProfile->_new_from_dbic($profile)
157         if ( $profile );
158     return;
159 }
160
161 =head3 housebound_role
162
163 Returns the HouseboundRole associated with this patron.
164
165 =cut
166
167 sub housebound_role {
168     my ( $self ) = @_;
169
170     my $role = $self->_result->housebound_role;
171     return Koha::Patron::HouseboundRole->_new_from_dbic($role) if ( $role );
172     return;
173 }
174
175 =head3 siblings
176
177 Returns the siblings of this patron.
178
179 =cut
180
181 sub siblings {
182     my ( $self ) = @_;
183
184     my $guarantor = $self->guarantor;
185
186     return unless $guarantor;
187
188     return Koha::Patrons->search(
189         {
190             guarantorid => {
191                 '!=' => undef,
192                 '=' => $guarantor->id,
193             },
194             borrowernumber => {
195                 '!=' => $self->borrowernumber,
196             }
197         }
198     );
199 }
200
201 =head3 wants_check_for_previous_checkout
202
203     $wants_check = $patron->wants_check_for_previous_checkout;
204
205 Return 1 if Koha needs to perform PrevIssue checking, else 0.
206
207 =cut
208
209 sub wants_check_for_previous_checkout {
210     my ( $self ) = @_;
211     my $syspref = C4::Context->preference("checkPrevCheckout");
212
213     # Simple cases
214     ## Hard syspref trumps all
215     return 1 if ($syspref eq 'hardyes');
216     return 0 if ($syspref eq 'hardno');
217     ## Now, patron pref trumps all
218     return 1 if ($self->checkprevcheckout eq 'yes');
219     return 0 if ($self->checkprevcheckout eq 'no');
220
221     # More complex: patron inherits -> determine category preference
222     my $checkPrevCheckoutByCat = $self->category->checkprevcheckout;
223     return 1 if ($checkPrevCheckoutByCat eq 'yes');
224     return 0 if ($checkPrevCheckoutByCat eq 'no');
225
226     # Finally: category preference is inherit, default to 0
227     if ($syspref eq 'softyes') {
228         return 1;
229     } else {
230         return 0;
231     }
232 }
233
234 =head3 do_check_for_previous_checkout
235
236     $do_check = $patron->do_check_for_previous_checkout($item);
237
238 Return 1 if the bib associated with $ITEM has previously been checked out to
239 $PATRON, 0 otherwise.
240
241 =cut
242
243 sub do_check_for_previous_checkout {
244     my ( $self, $item ) = @_;
245
246     # Find all items for bib and extract item numbers.
247     my @items = Koha::Items->search({biblionumber => $item->{biblionumber}});
248     my @item_nos;
249     foreach my $item (@items) {
250         push @item_nos, $item->itemnumber;
251     }
252
253     # Create (old)issues search criteria
254     my $criteria = {
255         borrowernumber => $self->borrowernumber,
256         itemnumber => \@item_nos,
257     };
258
259     # Check current issues table
260     my $issues = Koha::Checkouts->search($criteria);
261     return 1 if $issues->count; # 0 || N
262
263     # Check old issues table
264     my $old_issues = Koha::Old::Checkouts->search($criteria);
265     return $old_issues->count;  # 0 || N
266 }
267
268 =head3 is_debarred
269
270 my $debarment_expiration = $patron->is_debarred;
271
272 Returns the date a patron debarment will expire, or undef if the patron is not
273 debarred
274
275 =cut
276
277 sub is_debarred {
278     my ($self) = @_;
279
280     return unless $self->debarred;
281     return $self->debarred
282       if $self->debarred =~ '^9999'
283       or dt_from_string( $self->debarred ) > dt_from_string;
284     return;
285 }
286
287 =head3 is_expired
288
289 my $is_expired = $patron->is_expired;
290
291 Returns 1 if the patron is expired or 0;
292
293 =cut
294
295 sub is_expired {
296     my ($self) = @_;
297     return 0 unless $self->dateexpiry;
298     return 0 if $self->dateexpiry eq '0000-00-00';
299     return 1 if dt_from_string( $self->dateexpiry ) < dt_from_string->truncate( to => 'day' );
300     return 0;
301 }
302
303 =head3 is_going_to_expire
304
305 my $is_going_to_expire = $patron->is_going_to_expire;
306
307 Returns 1 if the patron is going to expired, depending on the NotifyBorrowerDeparture pref or 0
308
309 =cut
310
311 sub is_going_to_expire {
312     my ($self) = @_;
313
314     my $delay = C4::Context->preference('NotifyBorrowerDeparture') || 0;
315
316     return 0 unless $delay;
317     return 0 unless $self->dateexpiry;
318     return 0 if $self->dateexpiry eq '0000-00-00';
319     return 1 if dt_from_string( $self->dateexpiry )->subtract( days => $delay ) < dt_from_string->truncate( to => 'day' );
320     return 0;
321 }
322
323 =head3 update_password
324
325 my $updated = $patron->update_password( $userid, $password );
326
327 Update the userid and the password of a patron.
328 If the userid already exists, returns and let DBIx::Class warns
329 This will add an entry to action_logs if BorrowersLog is set.
330
331 =cut
332
333 sub update_password {
334     my ( $self, $userid, $password ) = @_;
335     eval { $self->userid($userid)->store; };
336     return if $@; # Make sure the userid is not already in used by another patron
337     $self->password($password)->store;
338     logaction( "MEMBERS", "CHANGE PASS", $self->borrowernumber, "" ) if C4::Context->preference("BorrowersLog");
339     return 1;
340 }
341
342 =head3 renew_account
343
344 my $new_expiry_date = $patron->renew_account
345
346 Extending the subscription to the expiry date.
347
348 =cut
349
350 sub renew_account {
351     my ($self) = @_;
352     my $date;
353     if ( C4::Context->preference('BorrowerRenewalPeriodBase') eq 'combination' ) {
354         $date = ( dt_from_string gt dt_from_string( $self->dateexpiry ) ) ? dt_from_string : dt_from_string( $self->dateexpiry );
355     } else {
356         $date =
357             C4::Context->preference('BorrowerRenewalPeriodBase') eq 'dateexpiry'
358             ? dt_from_string( $self->dateexpiry )
359             : dt_from_string;
360     }
361     my $expiry_date = $self->category->get_expiry_date($date);
362
363     $self->dateexpiry($expiry_date)->store;
364
365     $self->add_enrolment_fee_if_needed;
366
367     logaction( "MEMBERS", "RENEW", $self->borrowernumber, "Membership renewed" ) if C4::Context->preference("BorrowersLog");
368     return dt_from_string( $expiry_date )->truncate( to => 'day' );
369 }
370
371 =head3 has_overdues
372
373 my $has_overdues = $patron->has_overdues;
374
375 Returns the number of patron's overdues
376
377 =cut
378
379 sub has_overdues {
380     my ($self) = @_;
381     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
382     return $self->_result->issues->search({ date_due => { '<' => $dtf->format_datetime( dt_from_string() ) } })->count;
383 }
384
385 =head3 track_login
386
387     $patron->track_login;
388     $patron->track_login({ force => 1 });
389
390     Tracks a (successful) login attempt.
391     The preference TrackLastPatronActivity must be enabled. Or you
392     should pass the force parameter.
393
394 =cut
395
396 sub track_login {
397     my ( $self, $params ) = @_;
398     return if
399         !$params->{force} &&
400         !C4::Context->preference('TrackLastPatronActivity');
401     $self->lastseen( dt_from_string() )->store;
402 }
403
404 =head3 move_to_deleted
405
406 my $is_moved = $patron->move_to_deleted;
407
408 Move a patron to the deletedborrowers table.
409 This can be done before deleting a patron, to make sure the data are not completely deleted.
410
411 =cut
412
413 sub move_to_deleted {
414     my ($self) = @_;
415     my $patron_infos = $self->unblessed;
416     delete $patron_infos->{updated_on}; #This ensures the updated_on date in deletedborrowers will be set to the current timestamp
417     return Koha::Database->new->schema->resultset('Deletedborrower')->create($patron_infos);
418 }
419
420 =head3 article_requests
421
422 my @requests = $borrower->article_requests();
423 my $requests = $borrower->article_requests();
424
425 Returns either a list of ArticleRequests objects,
426 or an ArtitleRequests object, depending on the
427 calling context.
428
429 =cut
430
431 sub article_requests {
432     my ( $self ) = @_;
433
434     $self->{_article_requests} ||= Koha::ArticleRequests->search({ borrowernumber => $self->borrowernumber() });
435
436     return $self->{_article_requests};
437 }
438
439 =head3 article_requests_current
440
441 my @requests = $patron->article_requests_current
442
443 Returns the article requests associated with this patron that are incomplete
444
445 =cut
446
447 sub article_requests_current {
448     my ( $self ) = @_;
449
450     $self->{_article_requests_current} ||= Koha::ArticleRequests->search(
451         {
452             borrowernumber => $self->id(),
453             -or          => [
454                 { status => Koha::ArticleRequest::Status::Pending },
455                 { status => Koha::ArticleRequest::Status::Processing }
456             ]
457         }
458     );
459
460     return $self->{_article_requests_current};
461 }
462
463 =head3 article_requests_finished
464
465 my @requests = $biblio->article_requests_finished
466
467 Returns the article requests associated with this patron that are completed
468
469 =cut
470
471 sub article_requests_finished {
472     my ( $self, $borrower ) = @_;
473
474     $self->{_article_requests_finished} ||= Koha::ArticleRequests->search(
475         {
476             borrowernumber => $self->id(),
477             -or          => [
478                 { status => Koha::ArticleRequest::Status::Completed },
479                 { status => Koha::ArticleRequest::Status::Canceled }
480             ]
481         }
482     );
483
484     return $self->{_article_requests_finished};
485 }
486
487 =head3 add_enrolment_fee_if_needed
488
489 my $enrolment_fee = $patron->add_enrolment_fee_if_needed;
490
491 Add enrolment fee for a patron if needed.
492
493 =cut
494
495 sub add_enrolment_fee_if_needed {
496     my ($self) = @_;
497     my $enrolment_fee = $self->category->enrolmentfee;
498     if ( $enrolment_fee && $enrolment_fee > 0 ) {
499         # insert fee in patron debts
500         C4::Accounts::manualinvoice( $self->borrowernumber, '', '', 'A', $enrolment_fee );
501     }
502     return $enrolment_fee || 0;
503 }
504
505 =head3 checkouts
506
507 my $issues = $patron->checkouts
508
509 =cut
510
511 sub checkouts {
512     my ($self) = @_;
513     my $issues = $self->_result->issues;
514     return Koha::Checkouts->_new_from_dbic( $issues );
515 }
516
517 =head3 get_overdues
518
519 my $overdue_items = $patron->get_overdues
520
521 Return the overdued items
522
523 =cut
524
525 sub get_overdues {
526     my ($self) = @_;
527     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
528     return $self->checkouts->search(
529         {
530             'me.date_due' => { '<' => $dtf->format_datetime(dt_from_string) },
531         },
532         {
533             prefetch => { item => { biblio => 'biblioitems' } },
534         }
535     );
536 }
537
538 =head3 get_age
539
540 my $age = $patron->get_age
541
542 Return the age of the patron
543
544 =cut
545
546 sub get_age {
547     my ($self)    = @_;
548     my $today_str = dt_from_string->strftime("%Y-%m-%d");
549     return unless $self->dateofbirth;
550     my $dob_str   = dt_from_string( $self->dateofbirth )->strftime("%Y-%m-%d");
551
552     my ( $dob_y,   $dob_m,   $dob_d )   = split /-/, $dob_str;
553     my ( $today_y, $today_m, $today_d ) = split /-/, $today_str;
554
555     my $age = $today_y - $dob_y;
556     if ( $dob_m . $dob_d > $today_m . $today_d ) {
557         $age--;
558     }
559
560     return $age;
561 }
562
563 =head3 account
564
565 my $account = $patron->account
566
567 =cut
568
569 sub account {
570     my ($self) = @_;
571     return Koha::Account->new( { patron_id => $self->borrowernumber } );
572 }
573
574 =head3 holds
575
576 my $holds = $patron->holds
577
578 Return all the holds placed by this patron
579
580 =cut
581
582 sub holds {
583     my ($self) = @_;
584     my $holds_rs = $self->_result->reserves->search( {}, { order_by => 'reservedate' } );
585     return Koha::Holds->_new_from_dbic($holds_rs);
586 }
587
588 =head3 first_valid_email_address
589
590 =cut
591
592 sub first_valid_email_address {
593     my ($self) = @_;
594
595     return $self->email() || $self->emailpro() || $self->B_email() || q{};
596 }
597
598 =head3 get_club_enrollments
599
600 =cut
601
602 sub get_club_enrollments {
603     my ( $self, $return_scalar ) = @_;
604
605     my $e = Koha::Club::Enrollments->search( { borrowernumber => $self->borrowernumber(), date_canceled => undef } );
606
607     return $e if $return_scalar;
608
609     return wantarray ? $e->as_list : $e;
610 }
611
612 =head3 get_enrollable_clubs
613
614 =cut
615
616 sub get_enrollable_clubs {
617     my ( $self, $is_enrollable_from_opac, $return_scalar ) = @_;
618
619     my $params;
620     $params->{is_enrollable_from_opac} = $is_enrollable_from_opac
621       if $is_enrollable_from_opac;
622     $params->{is_email_required} = 0 unless $self->first_valid_email_address();
623
624     $params->{borrower} = $self;
625
626     my $e = Koha::Clubs->get_enrollable($params);
627
628     return $e if $return_scalar;
629
630     return wantarray ? $e->as_list : $e;
631 }
632
633 =head3 account_locked
634
635 my $is_locked = $patron->account_locked
636
637 Return true if the patron has reach the maximum number of login attempts (see pref FailedLoginAttempts).
638 Otherwise return false.
639 If the pref is not set (empty string, null or 0), the feature is considered as disabled.
640
641 =cut
642
643 sub account_locked {
644     my ($self) = @_;
645     my $FailedLoginAttempts = C4::Context->preference('FailedLoginAttempts');
646     return ( $FailedLoginAttempts
647           and $self->login_attempts
648           and $self->login_attempts >= $FailedLoginAttempts )? 1 : 0;
649 }
650
651 =head3 type
652
653 =cut
654
655 sub _type {
656     return 'Borrower';
657 }
658
659 =head1 AUTHOR
660
661 Kyle M Hall <kyle@bywatersolutions.com>
662 Alex Sassmannshausen <alex.sassmannshausen@ptfs-europe.com>
663
664 =cut
665
666 1;