From 03eeeceb13a097084be30e77d9ddcf7a4450a374 Mon Sep 17 00:00:00 2001 From: Aleisha Amohia Date: Thu, 23 Apr 2020 15:36:19 +0000 Subject: [PATCH] Bug 19532: Recalls on OPAC Placing an item-level or biblio-level recall Viewing current and past recalls Recalls work with course reserves --- C4/Reserves.pm | 5 + .../bootstrap/en/includes/item-status.inc | 1 + .../bootstrap/en/includes/opac-detail-sidebar.inc | 5 + .../opac-tmpl/bootstrap/en/includes/usermenu.inc | 9 + .../bootstrap/en/modules/opac-course-details.tt | 2 +- .../opac-tmpl/bootstrap/en/modules/opac-recall.tt | 195 +++++++++++++++++++++ .../opac-tmpl/bootstrap/en/modules/opac-reserve.tt | 2 + .../opac-tmpl/bootstrap/en/modules/opac-user.tt | 82 +++++++++ opac/opac-course-details.pl | 6 + opac/opac-detail.pl | 3 +- opac/opac-recall.pl | 190 ++++++++++++++++++++ opac/opac-reserve.pl | 4 + opac/opac-user.pl | 7 + 13 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-recall.tt create mode 100644 opac/opac-recall.pl diff --git a/C4/Reserves.pm b/C4/Reserves.pm index 9c040bdc12c..507dc73f941 100644 --- a/C4/Reserves.pm +++ b/C4/Reserves.pm @@ -342,6 +342,7 @@ sub CanBookBeReserved{ { status => libraryNotPickupLocation }, if given branchcode is not configured to be a pickup location { status => cannotBeTransferred }, if branch transfer limit applies on given item and branchcode { status => pickupNotInHoldGroup }, pickup location is not in hold group, and pickup locations are only allowed from hold groups. + { status => recall }, if the borrower has already placed a recall on this item =cut @@ -375,6 +376,10 @@ sub CanItemBeReserved { return { status =>'itemAlreadyOnHold' } if Koha::Holds->search( { borrowernumber => $borrowernumber, itemnumber => $itemnumber } )->count(); + # check if a recall exists on this item from this borrower + return { status => 'recall' } + if Koha::Recalls->search({ borrowernumber => $borrowernumber, itemnumber => $itemnumber, old => undef })->count; + my $controlbranch = C4::Context->preference('ReservesControlBranch'); my $querycount = q{ diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/item-status.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/item-status.inc index c15566a7be4..1d172a8a1ff 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/item-status.inc +++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/item-status.inc @@ -38,6 +38,7 @@ Checked out [% END %] [% END %] + [% IF item.avail_for_recall %]Recall[% END %] [% END %] [% IF NOT ( item.isa('Koha::Item') ) AND item.transfertwhen %] [%# transfertwhen is set in C4::Search, do not have it for course reserves %] diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-detail-sidebar.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-detail-sidebar.inc index 165421e5514..1687d85c030 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-detail-sidebar.inc +++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-detail-sidebar.inc @@ -11,6 +11,11 @@ [% END %] [% END %] + [% IF Koha.Preference('UseRecalls') %] +
  • + Place recall
  • + [% END %] +
  • Print
  • [% IF Koha.Preference( 'opacuserlogin' ) == 1 %] diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc index f1cdfa5ab27..c4b1a408263 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc +++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc @@ -131,6 +131,15 @@ your interlibrary loan requests [% END %] + [% IF Koha.Preference('UseRecalls') %] + [% IF ( recallsview ) %] +
  • + [% ELSE %] +
  • + [% END %] + your recalls history + [% END %] + [% END %] diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-course-details.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-course-details.tt index 5db0b526d3f..569b4ab14fe 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-course-details.tt +++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-course-details.tt @@ -76,7 +76,7 @@ [% AuthorisedValues.GetByCode( 'CCODE', cr.item.ccode, 1 ) | html %] [% cr.item.itemcallnumber | html %] [% cr.item.copynumber | html %] - [% INCLUDE 'item-status.inc' item=cr.item issue=cr.issue %] + [% INCLUDE 'item-status.inc' item=cr.course_item issue=cr.issue %] [% cr.issue.date_due | $KohaDates as_due_date => 1 %] [% IF ( cr.public_note ) %] [% cr.public_note | $raw %] diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-recall.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-recall.tt new file mode 100644 index 00000000000..6fd4a011f10 --- /dev/null +++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-recall.tt @@ -0,0 +1,195 @@ +[% USE Koha %] +[% USE KohaDates %] +[% USE Branches %] +[% USE ItemTypes %] +[% USE AuthorisedValues %] +[% SET footerjs = 1 %] +[% INCLUDE 'doc-head-open.inc' %] +[% IF ( LibraryNameTitle ) %][% LibraryNameTitle %][% ELSE %]Koha online[% END %] catalog › Recall +[% INCLUDE 'doc-head-close.inc' %] +[% BLOCK cssinclude %][% END %] + + +[% INCLUDE 'masthead.inc' %] + +
    + + +
    +
    +
    + +
    +
    +
    +

    Confirm recalls for: [% INCLUDE 'patron-title.inc' patron = logged_in_user %] ([% logged_in_user.cardnumber | html %])

    + [% IF nosyspref %] + Recalls have not been enabled. Please contact your library. + [% ELSIF error == 'unavailable' %] + There are no items available for recall. + Return to [% biblio.title %] + [% ELSE %] + [% IF error %] +
    + [% IF error == 'notloggedin' %] + Please log in to place a recall. + [% ELSIF error == 'duplicate' %] + You have reached the maximum number of recalls on this record. + [% ELSIF error == 'unavailable' %] + There are no items available for recall. + [% ELSE %] + An error has occurred while attempting to place a recall. Please contact your library. + [% END %] +
    + [% END %] + + [% IF success %] +

    Your recall has been placed. The patron the item is currently checked out to has been asked to return the item within [% due_interval %] days, by [% due_date | $KohaDates %].

    +

    You will be notified when your item is waiting to be picked up at the library.

    + [% ELSIF not error %] +

    All borrowable material is subject to recall if checked out and needed by someone else. We will ask the person who has checked out this item to return it so you may use it.

    + [% IF loggedinusername %] +
    + +
    +
    + Place a recall on [% biblio.title %] ([% biblio.author %])? +
      + + [% UNLESS ( single_branch_mode ) %]
    • + + +
    • [% END %] + +
    • [% INCLUDE 'date-format.inc' %]
    • + + + + + + + + + + [% UNLESS ( single_branch_mode ) %] + + + [% END %] + + + + + + + [% FOREACH item IN items %] + + + + [% UNLESS ( single_branch_mode ) %] + + + [% END %] + + + + + + [% END %] +
      Select a specific item:
       Item typeBarcodeHome libraryLast locationCollectionCall numberCopy numberVol infoInformation
      + [% IF item.can_be_recalled({ patron => logged_in_user }) %] + + [% ELSE %] + + Cannot be recalled + [% END %] + [% ItemTypes.GetDescription( item.get_effective_itemtype ) %][% item.barcode %][% Branches.GetName( item.homebranch ) | html %][% Branches.GetName( item.holdingbranch) | html %][% AuthorisedValues.GetByCode( 'CCODE', item.ccode, 1 ) | html %][% item.itemcallnumber %][% item.copynumber %][% item.enumchron %] + [% IF ( item.checkout ) %] + Due [% item.checkout.date_due | $KohaDates %] + [% ELSIF ( item.get_transfer ) %] + In transit from [% Branches.GetName( item.get_transfer.frombranch ) | html %] to [% Branches.GetName( item.get_transfer.tobranch ) | html %] since [% item.get_transfer.datesent | $KohaDates %] + [% END %] + [% IF ( item.itemlost || item.withdrawn ) %] + Unavailable (lost or missing) + [% END %] + [% IF ( item.notforloan ) %] + Not for loan ([% item.notforloan | html %]) + [% END %] + [% hold = item.current_holds.next %] + [% IF ( item.recall ) %] + + [% IF ( item.recall.waitingdate ) %] + Waiting for patron at [% Branches.GetName( item.recall.branchcode ) | html %] since [% item.recall.waitingdate | $KohaDates %]. + [% ELSE %] + Recalled by patron expected at [% Branches.GetName( item.recall.branchcode ) | html %] since [% item.recall.recalldate | $KohaDates %]. + [% END %] + + [% ELSIF ( hold ) %] + + Waiting for patron at [% Branches.GetName( hold.branchcode ) | html %] since [% hold.waitingdate | $KohaDates %]. + + [% END # / IF ( item.recall or hold ) %] +
      +
    +
    + + + + Cancel +
    +
    + +
    + [% ELSE %] +
    You must be logged in to place a recall.
    + [% END %] + [% END %] + [% END %] +
    +
    +
    +
    +
    + +[% INCLUDE 'opac-bottom.inc' %] +[% BLOCK jsinclude %] +[% INCLUDE 'calendar.inc' %] + +[% END %] diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-reserve.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-reserve.tt index 9e73090c6ea..e60a2345d70 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-reserve.tt +++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-reserve.tt @@ -193,6 +193,8 @@ [% END %] [% IF ( bibitemloo.already_reserved ) %]
    You have already requested this title.
    + [% ELSIF ( bibitemloo.recall ) %] +
    You have already placed a recall on this title.
    [% ELSE %] [% UNLESS ( bibitemloo.bib_available ) %]
    There are no items that can be placed on hold.
    diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-user.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-user.tt index e101127ca27..a5684316f2b 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-user.tt +++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-user.tt @@ -202,6 +202,7 @@ [% END %] [% IF ( RESERVES.count ) %]
  • Holds ([% RESERVES.count | html %])
  • [% END %] + [% IF Koha.Preference('UseRecalls') && RECALLS.count %]
  • Recalls ([% RECALLS.count | html %])
  • [% END %] [% IF Koha.Preference('ArticleRequests') && logged_in_user.article_requests_current %]
  • Article requests ([% logged_in_user.article_requests_current.count | html %])
  • [% END %] [% IF ( OverDriveCirculation ) %]
  • OverDrive Account
  • @@ -300,6 +301,8 @@ [% IF ( Koha.Preference('OpacStarRatings') == 'all' ) %] [% INCLUDE 'user-star-ratings.inc' item=ISSUE %] [% END %] + + [% IF ( ISSUE.recall ) %]
    This item has been recalled. Please return by the new due date.[% END %] [% ISSUE.author | html %] @@ -654,6 +657,81 @@ [% PROCESS 'holds-table.inc' HOLDS = RESERVES, SuspendHoldsOpac = SuspendHoldsOpac, showpriority = showpriority, AutoResumeSuspendedHolds = AutoResumeSuspendedHolds %] + [% IF Koha.Preference('UseRecalls') && RECALLS.count %] +
    + + + + + + + + + + + + + + [% FOREACH RECALL IN RECALLS %] + + + + + + + + [% END %] + + +
    Recalls ([% RECALLS.count %])
    TitlePlaced onExpires onPickup locationStatus 
    + + [% RECALL.biblio.title %] + [% FOREACH s IN RECALL.biblio.subtitles %] + [% s %] + [% END %] + [% RECALL.item.enumchron %] + + [% RECALL.biblio.author %] + + + Recall date: + [% RECALL.recalldate | $KohaDates %] + + + [% IF ( RECALL.expirationdate ) %] + + Expiration: + [% RECALL.expirationdate | $KohaDates %] + + [% ELSE %] + + Expiration: + Never expires + + [% END %] + + Pick up location: + [% RECALL.library.branchname %] + + Status: + [% IF ( RECALL.requested ) %] + Requested + [% ELSIF ( RECALL.waiting ) %] + Ready for pickup + [% END %] + + [% IF ( !RECALL.cancellationdate && ( RECALL.requested || RECALL.overdue ) ) %] +
    + + + + +
    + [% END %] +
    +
    + [% END # / # RECALLS.count %] + [% IF Koha.Preference('ArticleRequests') %]
    [% IF logged_in_user.article_requests_current.count %] @@ -965,6 +1043,10 @@ $('#opac-user-clubs').load('/cgi-bin/koha/clubs/clubs-tab.pl?borrowernumber=[% borrowernumber | html %]'); }); } + + $("#cancel_recall").click(function(e){ + return confirmDelete(_("Are you sure you want to undo this recall?")); + }); }); //]]> var borrowernumber = "[% borrowernumber | html %]"; diff --git a/opac/opac-course-details.pl b/opac/opac-course-details.pl index 3afa8a921a3..d3e6b2adef8 100755 --- a/opac/opac-course-details.pl +++ b/opac/opac-course-details.pl @@ -46,6 +46,12 @@ die("No course_id given") unless ($course_id); my $course = GetCourse($course_id); my $course_reserves = GetCourseReserves( course_id => $course_id, include_items => 1, include_count => 1 ); +foreach my $cr ( @$course_reserves ) { + if ( $cr->{'issue'}->{date_due} and $cr->{'issue'}->{borrowernumber} and $borrowernumber != $cr->{'issue'}->{borrowernumber} and C4::Context->preference('UseRecalls') ) { + $cr->{'course_item'}->{avail_for_recall} = 1; + } +} + $template->param( course => $course, course_reserves => $course_reserves, diff --git a/opac/opac-detail.pl b/opac/opac-detail.pl index f1100878bd4..5e2890839ec 100755 --- a/opac/opac-detail.pl +++ b/opac/opac-detail.pl @@ -703,7 +703,8 @@ if ( not $viewallitems and @items > $max_items_to_display ) { } my $reserve_status = C4::Reserves::GetReserveStatus($itm->{itemnumber}); - if( $reserve_status eq "Waiting"){ $itm->{'waiting'} = 1; } + my $recall_status = Koha::Recalls->find({ itemnumber => $itm->{itemnumber}, status => 'W', old => undef }); + if( $reserve_status eq "Waiting" or defined $recall_status ){ $itm->{'waiting'} = 1; } if( $reserve_status eq "Reserved"){ $itm->{'onhold'} = 1; } my ( $transfertwhen, $transfertfrom, $transfertto ) = GetTransfers($itm->{itemnumber}); diff --git a/opac/opac-recall.pl b/opac/opac-recall.pl new file mode 100644 index 00000000000..c85f0700852 --- /dev/null +++ b/opac/opac-recall.pl @@ -0,0 +1,190 @@ +#!/usr/bin/perl + +# This file is part of Koha. +# +# Copyright 2020 Aleisha Amohia +# +# 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 CGI qw ( -utf8 ); +use DateTime; +use C4::Auth; +use C4::Output; +use C4::Context; +use C4::Letters; +use C4::Stats; +use Koha::Items; +use Koha::Recall; +use Koha::Recalls; +use Koha::DateUtils; +use Koha::Patrons; + +my $query = new CGI; +my ( $template, $borrowernumber, $cookie ) = get_template_and_user( + { + template_name => "opac-recall.tt", + query => $query, + type => "opac", + authnotrequired => 0, + } +); + +my $op = $query->param('op') || ''; +my $biblionumber = $query->param('biblionumber'); + +unless ( C4::Context->preference('UseRecalls') ) { + print $query->redirect("/cgi-bin/koha/opac-recall.pl?biblionumber=$biblionumber&nosyspref=1"); + exit; +} + +my $biblio = Koha::Biblios->find($biblionumber); +my $borrower = Koha::Patrons->find($borrowernumber); +my $error; + +unless ( $biblio->can_be_recalled({ patron => $borrower }) ) { $error = 'unavailable'; } + +my $items = Koha::Items->search({ biblionumber => $biblionumber }); + +# check if already recalled +my $recalled = Koha::Recalls->search({ biblionumber => $biblionumber, borrowernumber => $borrowernumber, old => undef })->count; +if ( defined $recalled ) { + my $recalls_per_record = Koha::CirculationRules->get_effective_rule({ categorycode => $borrower->categorycode, branchcode => undef, itemtype => undef, rule_name => 'recalls_per_record' }); + + if ( defined $recalls_per_record and $recalled >= $recalls_per_record->{rule_value} ){ + $error = 'duplicate'; + } +} + +# submitting recall request +if ($op eq 'request'){ + + if (!defined $borrowernumber){ + # can't place recall if not logged in + $error = 'notloggedin'; + print $query->redirect("/cgi-bin/koha/opac-detail.pl?biblionumber=$biblionumber"); + + } elsif ( defined $error and $error eq 'unavailable' ){ + # no items available for recall + print $query->redirect("/cgi-bin/koha/opac-recall.pl?biblionumber=$biblionumber&error=unavailable"); + + } elsif ( !defined $error ){ + # can recall + + # check if bib-level or item-level recall + my $level = $query->param('type'); + my $itemnumber; + if ( $level eq 'itemlevel' ) { $itemnumber = $query->param('itemnumber'); } + + my $expirationdate = scalar $query->param('expirationdate'); + if ( $expirationdate ){ + my $now = dt_from_string; + $expirationdate = dt_from_string($expirationdate)->set({ hour => $now->hour, minute => $now->minute, second => $now->second }); + } + + my $pickup = $query->param('pickup') || $borrower->branchcode; + + # attempt recall + my $recall_request; + if ( $item->can_be_recalled({ patron => $borrower }) ) { + $recall_request = Koha::Recall->new({ + borrowernumber => $borrowernumber, + recalldate => dt_from_string(), + biblionumber => $biblio->biblionumber, + branchcode => $pickup, + status => 'R', + itemnumber => defined $itemnumber ? $itemnumber : undef, + itemtype => $item->itype, + expirationdate => $expirationdate, + item_level_recall => defined $itemnumber ? 1 : 0 + })->store; + } + + if (defined $recall_request->recall_id){ # successful recall + my $recall = Koha::Recalls->find($recall_request->recall_id); + + # set up vars + my $timestamp = dt_from_string( $recall->timestamp ); + my $due_date; + my $checkout_borrower; + + # get checkout and adjust due date + my $checkout = $recall->checkout; + my $recall_due_date_interval = Koha::CirculationRules->get_effective_rule({ + categorycode => $checkout->patron->categorycode, + itemtype => $checkout->item->effective_itemtype, + branchcode => C4::Context->userenv->{'branch'}, + rule_name => 'recall_due_date_interval', + }); + unless ( defined $recall_due_date_interval ) { $recall_due_date_interval->{rule_value} = 5 }; + $due_date = $timestamp->add( days => $recall_due_date_interval->{rule_value} ); + $checkout->update({ date_due => $due_date }); + unless ( $recall->item_level_recall ) { $itemnumber = $checkout->itemnumber; } + + # send notice to user with recalled item checked out + my $letter = C4::Letters::GetPreparedLetter ( + module => 'circulation', + letter_code => 'RETURN_RECALLED_ITEM', + branchcode => $recall->branchcode, + tables => { + biblio => $biblio->biblionumber, + borrowers => $checkout->borrowernumber, + items => $itemnumber, + issues => $itemnumber, + }, + ); + + C4::Message->enqueue($letter, $checkout->patron->unblessed, 'email'); + + my $item = Koha::Items->find($itemnumber); + # add to statistics table + UpdateStats({ + branch => C4::Context->userenv->{'branch'}, + type => 'recall', + itemnumber => $itemnumber, + borrowernumber => $borrowernumber, + itemtype => $item->effective_itemtype, + ccode => $item->ccode, + }); + + # add action log + C4::Log::logaction( 'RECALLS', 'CREATE', $recall->recall_id, "Recall requested by borrower #" . $recall->borrowernumber, 'OPAC') if ( C4::Context->preference('RecallsLog') ); + $template->param( + success => 1, + due_interval => $recall_due_date_interval->{rule_value}, + due_date => $checkout->date_due, + ); + } else { + $error = 'failed'; + } + } +} elsif ($op eq 'cancel'){ + my $recall_id = $query->param('recall_id'); + Koha::Recalls->find($recall_id)->set_cancelled; + print $query->redirect('/cgi-bin/koha/opac-user.pl'); +} + +my $branches = Koha::Libraries->search(); +my $single_branch_mode = $branches->count == 1; + +$template->param( + biblio => $biblio, + error => $error, + items => $items, + single_branch_mode => $single_branch_mode, + branches => $branches, +); + +output_with_http_headers $query, $cookie, $template->output, 'html'; + diff --git a/opac/opac-reserve.pl b/opac/opac-reserve.pl index 2a11ad48e58..1e272139826 100755 --- a/opac/opac-reserve.pl +++ b/opac/opac-reserve.pl @@ -623,6 +623,10 @@ foreach my $biblioNum (@biblionumbers) { $biblioLoopIter{holdable} &&= CanBookBeReserved( $borrowernumber, $biblioNum )->{status} eq 'OK'; + if ( CanBookBeReserved( $borrowernumber, $biblioNum )->{status} eq 'recall' ){ + $biblioLoopIter{recall} = 1; + } + # For multiple holds per record, if a patron has previously placed a hold, # the patron can only place more holds of the same type. That is, if the # patron placed a record level hold, all the holds the patron places must diff --git a/opac/opac-user.pl b/opac/opac-user.pl index 6d967c88031..71a69b77c81 100755 --- a/opac/opac-user.pl +++ b/opac/opac-user.pl @@ -297,6 +297,11 @@ if ( $pending_checkouts->count ) { # Useless test $issue->{biblionumber} ? $my_summary_html =~ s/{BIBLIONUMBER}/$issue->{biblionumber}/g : $my_summary_html =~ s/{BIBLIONUMBER}//g; $issue->{MySummaryHTML} = $my_summary_html; } + + my $recall = Koha::Recalls->find({ itemnumber => $issue->{itemnumber}, old => undef }); + if ( defined $recall ) { + $issue->{recalled} = 1; + } } } my $overduesblockrenewing = C4::Context->preference('OverduesBlockRenewing'); @@ -318,10 +323,12 @@ $template->param( show_barcode => 1 ) if $show_barcode; # now the reserved items.... my $reserves = Koha::Holds->search( { borrowernumber => $borrowernumber } ); +my $recalls = Koha::Recalls->search({ borrowernumber => $borrowernumber, old => undef }); $template->param( RESERVES => $reserves, showpriority => $show_priority, + RECALLS => $recalls, ); if (C4::Context->preference('BakerTaylorEnabled')) { -- 2.11.0