From 5aa22da651816ea218a6694614fa43dc6419357a Mon Sep 17 00:00:00 2001 From: Agustin Moyano Date: Sun, 9 Feb 2020 17:53:54 -0300 Subject: [PATCH] Bug 20936: Add patron's hold history menu in OPAC This patch adds patron's hold history in OPAC. Right now, it only shows records from old_reserves table, but I'll wait till bug 20271 is pushed to show full history (old and new holds) To test: 1. apply this patch 2. Find a patron, place several holds and cancel or fulfill them 3. Go to patron's opac CHECK => There is no 'your holds history' option in menu 4. In admin preferences enable OPACHoldsHistory 5. Go back to patron's opac SUCCESS => There is a 'your holds history' menu option => Holds history displays all holds canceled or fulfilled 6. Filter, order and change page SUCCESS => All controls work as expected 7. Sign off. Table content is fetched from the api. If you see data, and you can order and filter then please sign off bug 24561. Date columns use $date function to transform dates strings from api (for example '2020-02-20') to 'dateformat' prefernce format ('02/20/20202'). If you change dateformat prefernce and see the changes reflected in date columns, please sign off bug 24455. Signed-off-by: Kyle M Hall --- Koha/REST/V1/Patrons/Holds.pm | 174 ++++++++++++ Koha/Schema/Result/Biblio.pm | 20 ++ Koha/Schema/Result/Item.pm | 27 ++ Koha/Schema/Result/OldReserve.pm | 43 +++ api/v1/swagger/paths.json | 3 + api/v1/swagger/paths/public_patrons.json | 188 +++++++++++++ .../bootstrap/en/includes/usermenu.inc | 24 +- .../bootstrap/en/modules/opac-holdsrecord.tt | 247 ++++++++++++++++++ opac/opac-holdsrecord.pl | 161 ++++++++++++ 9 files changed, 880 insertions(+), 7 deletions(-) create mode 100644 Koha/REST/V1/Patrons/Holds.pm create mode 100644 koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-holdsrecord.tt create mode 100755 opac/opac-holdsrecord.pl diff --git a/Koha/REST/V1/Patrons/Holds.pm b/Koha/REST/V1/Patrons/Holds.pm new file mode 100644 index 0000000000..65be2c4b4d --- /dev/null +++ b/Koha/REST/V1/Patrons/Holds.pm @@ -0,0 +1,174 @@ +package Koha::REST::V1::Patrons::Holds; + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; + +use Koha::Patrons; + +use Scalar::Util qw(blessed); +use Koha::DateUtils; +use Try::Tiny; + +=head1 NAME + +Koha::REST::V1::Patrons::Holds + +=head1 API + +=head2 Methods + +=head3 list + +Mehtod that handles listing patron's holds + +=cut + +sub list { + my $c = shift->openapi->valid_input or return; + my $patron_id = $c->validation->param('patron_id'); + my $old = $c->validation->param('old'); + my $patron = Koha::Patrons->find($patron_id); + + unless ($patron) { + return $c->render( status => 404, openapi => { error => "Patron not found." } ); + } + + return try { + my $holds_set; + if($old) { + $holds_set = Koha::Old::Holds->new; + } else { + $holds_set = Koha::Holds->new; + } + + delete $c->validation->output->{old}; + #$c->stash_embed( {spec => $c->match->endpoint->pattern->defaults->{'openapi.op_spec'}} ); + my $holds = $c->objects->search( $holds_set ); + return $c->render( status => 200, openapi => $holds ); + } + catch { + warn $_; +use Data::Printer colored=>1; +p($_); + warn $_->{trace}; + if ( blessed $_ && $_->isa('Koha::Exceptions') ) { + return $c->render( + status => 500, + openapi => { error => "$_" } + ); + } + else { + return $c->render( + status => 500, + openapi => { error => "Something went wrong, check Koha logs for details." } + ); + } + }; +} + +=head2 Internal methods + +=head3 _to_model + +Helper function that maps REST api objects into Koha::Hold +attribute names. + +=cut + +sub _to_model { + my $hold = shift; +use Data::Printer colored=>1; +p($hold); + foreach my $attribute ( keys %{ $Koha::REST::V1::Patrons::Holds::to_model_mapping } ) { + my $mapped_attribute = $Koha::REST::V1::Patrons::Holds::to_model_mapping->{$attribute}; + if ( exists $hold->{ $attribute } + && defined $mapped_attribute ) + { + # key => !undef + $hold->{ $mapped_attribute } = delete $hold->{ $attribute }; + } + elsif ( exists $hold->{ $attribute } + && !defined $mapped_attribute ) + { + # key => undef / to be deleted + delete $hold->{ $attribute }; + } + } + + if ( exists $hold->{lowestPriority} ) { + $hold->{lowestPriority} = ($hold->{lowestPriority}) ? 1 : 0; + } + + if ( exists $hold->{suspend} ) { + $hold->{suspend} = ($hold->{suspend}) ? 1 : 0; + } + + if ( exists $hold->{reservedate} && $hold->{reservedate} != 1 ) { + $hold->{reservedate} = output_pref({ str => $hold->{reservedate}, dateformat => 'sql' }); + } + + if ( exists $hold->{cancellationdate} && $hold->{cancellationdate} != 1 ) { + $hold->{cancellationdate} = output_pref({ str => $hold->{cancellationdate}, dateformat => 'sql' }); + } + + if ( exists $hold->{timestamp} && $hold->{timestamp} != 1 ) { + $hold->{timestamp} = output_pref({ str => $hold->{timestamp}, dateformat => 'sql' }); + } + + if ( exists $hold->{waitingdate} && $hold->{waitingdate} != 1 ) { + $hold->{waitingdate} = output_pref({ str => $hold->{waitingdate}, dateformat => 'sql' }); + } + + if ( exists $hold->{expirationdate} && $hold->{expirationdate} != 1 ) { + $hold->{expirationdate} = output_pref({ str => $hold->{expirationdate}, dateformat => 'sql' }); + } + + if ( exists $hold->{suspend_until} && $hold->{suspend_until} != 1 ) { + $hold->{suspend_until} = output_pref({ str => $hold->{suspend_until}, dateformat => 'sql' }); + } + + return $hold; +} + +=head2 Global variables + +=head3 $to_model_mapping + +=cut + +our $to_model_mapping = { + hold_id => 'reserve_id', + patron_id => 'borrowernumber', + hold_date => 'reservedate', + biblio_id => 'biblionumber', + pickup_library_id => 'branchcode', + cancelation_date => 'cancellationdate', + notes => 'reservenotes', + status => 'found', + item_id => 'itemnumber', + waiting_date => 'waitingdate', + expiration_date => 'expirationdate', + lowest_priority => 'lowestPriority', + suspended => 'suspend', + suspended_until => 'suspend_until', + item_type => 'itemtype', + item_level => 'item_level_hold', +}; + +1; \ No newline at end of file diff --git a/Koha/Schema/Result/Biblio.pm b/Koha/Schema/Result/Biblio.pm index 7c66b1dc03..b6ef833bec 100644 --- a/Koha/Schema/Result/Biblio.pm +++ b/Koha/Schema/Result/Biblio.pm @@ -428,4 +428,24 @@ __PACKAGE__->add_columns( "+serial" => { is_boolean => 1 } ); +=head2 koha_objects_class + +Returns related Koha::Objects class name + +=cut + +sub koha_objects_class { + 'Koha::Biblios'; +} + +=head2 koha_object_class + +Returns related Koha::Object class name + +=cut + +sub koha_object_class { + 'Koha::Biblio'; +} + 1; diff --git a/Koha/Schema/Result/Item.pm b/Koha/Schema/Result/Item.pm index 58cddf4502..0afa3e5472 100644 --- a/Koha/Schema/Result/Item.pm +++ b/Koha/Schema/Result/Item.pm @@ -759,6 +759,13 @@ __PACKAGE__->belongs_to( ); use C4::Context; + +=head2 koha_objects_class + +Returns item's effective itemtype + +=cut + sub effective_itemtype { my ( $self ) = @_; @@ -772,4 +779,24 @@ sub effective_itemtype { } } +=head2 koha_objects_class + +Returns related Koha::Objects class name + +=cut + +sub koha_objects_class { + 'Koha::Items'; +} + +=head2 koha_object_class + +Returns related Koha::Object class name + +=cut + +sub koha_object_class { + 'Koha::Item'; +} + 1; diff --git a/Koha/Schema/Result/OldReserve.pm b/Koha/Schema/Result/OldReserve.pm index de9fc6cb27..87c96b1056 100644 --- a/Koha/Schema/Result/OldReserve.pm +++ b/Koha/Schema/Result/OldReserve.pm @@ -300,6 +300,49 @@ __PACKAGE__->belongs_to( # Created by DBIx::Class::Schema::Loader v0.07046 @ 2019-06-17 07:24:39 # DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:ZgGAW7ODBby3hGNJ41eeMA +__PACKAGE__->belongs_to( + "item", + "Koha::Schema::Result::Item", + { itemnumber => "itemnumber" }, + { + is_deferrable => 1, + join_type => "LEFT", + on_delete => "CASCADE", + on_update => "CASCADE", + }, +); + +__PACKAGE__->belongs_to( + "branch", + "Koha::Schema::Result::Branch", + { branchcode => "branchcode" }, + { + is_deferrable => 1, + join_type => "LEFT", + on_delete => "CASCADE", + on_update => "CASCADE", + }, +); + + +__PACKAGE__->belongs_to( + "biblio", + "Koha::Schema::Result::Biblio", + { biblionumber => "biblionumber" }, + { + is_deferrable => 1, + join_type => "LEFT", + on_delete => "CASCADE", + on_update => "CASCADE", + }, +); + +__PACKAGE__->add_columns( + '+item_level_hold' => { is_boolean => 1 }, + '+lowestPriority' => { is_boolean => 1 }, + '+suspend' => { is_boolean => 1 } +); + sub koha_object_class { 'Koha::Old::Hold'; } diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json index ed9ed0627c..be15293d0a 100644 --- a/api/v1/swagger/paths.json +++ b/api/v1/swagger/paths.json @@ -98,6 +98,9 @@ "/public/patrons/{patron_id}/guarantors/can_see_checkouts": { "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1guarantors~1can_see_checkouts" }, + "/public/patrons/{patron_id}/holds": { + "$ref": "paths/public_patrons.json#/~1public~1patrons~1{patron_id}~1holds" + }, "/return_claims": { "$ref": "paths/return_claims.json#/~1return_claims" }, diff --git a/api/v1/swagger/paths/public_patrons.json b/api/v1/swagger/paths/public_patrons.json index 5db073c4eb..a62b8e6ede 100644 --- a/api/v1/swagger/paths/public_patrons.json +++ b/api/v1/swagger/paths/public_patrons.json @@ -236,5 +236,193 @@ "allow-owner": true } } + }, + "/public/patrons/{patron_id}/holds": { + "get": { + "x-mojo-to": "Patrons::Holds#list", + "operationId": "listPatronsOldHolds", + "tags": [ + "patron" + ], + "parameters": [ + { + "$ref": "../parameters.json#/patron_id_pp" + }, + { + "name": "hold_id", + "in": "query", + "description": "Internal reserve identifier", + "type": "integer" + }, + { + "name": "hold_date", + "in": "query", + "description": "Hold", + "type": "string", + "format": "date" + }, + { + "name": "biblio_id", + "in": "query", + "description": "Internal biblio identifier", + "type": "integer" + }, + { + "name": "pickup_library_id", + "in": "query", + "description": "Internal library identifier for the pickup library", + "type": "string" + }, + { + "name": "cancelation_date", + "in": "query", + "description": "The date the hold was cancelled", + "type": "string", + "format": "date" + }, + { + "name": "notes", + "in": "query", + "description": "Notes related to this hold", + "type": "string" + }, + { + "name": "priority", + "in": "query", + "description": "Where in the queue the patron sits", + "type": "integer" + }, + { + "name": "status", + "in": "query", + "description": "Found status", + "type": "string" + }, + { + "name": "timestamp", + "in": "query", + "description": "Time of latest update", + "type": "string" + }, + { + "name": "item_id", + "in": "query", + "description": "Internal item identifier", + "type": "integer" + }, + { + "name": "waiting_date", + "in": "query", + "description": "Date the item was marked as waiting for the patron", + "type": "string" + }, + { + "name": "expiration_date", + "in": "query", + "description": "Date the hold expires", + "type": "string" + }, + { + "name": "lowest_priority", + "in": "query", + "description": "Lowest priority", + "type": "boolean" + }, + { + "name": "suspended", + "in": "query", + "description": "Suspended", + "type": "boolean" + }, + { + "name": "suspended_until", + "in": "query", + "description": "Suspended until", + "type": "string" + }, + { + "name": "old", + "in": "query", + "description": "Fetch holds history", + "type": "boolean" + }, + { + "$ref": "../parameters.json#/match" + }, + { + "$ref": "../parameters.json#/order_by" + }, + { + "$ref": "../parameters.json#/page" + }, + { + "$ref": "../parameters.json#/per_page" + }, + { + "$ref": "../parameters.json#/q_param" + }, + { + "$ref": "../parameters.json#/q_body" + }, + { + "$ref": "../parameters.json#/q_header" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "List of holds history", + "schema": { + "$ref": "../definitions.json#/holds" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "401": { + "description": "Authentication required", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "403": { + "description": "Access forbidden", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "404": { + "description": "Patron not found", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "../definitions.json#/error" + } + }, + "503": { + "description": "Under maintenance", + "schema": { + "$ref": "../definitions.json#/error" + } + } + }, + "x-koha-authorization": { + "allow-owner": true + }, + "x-koha-embed": [ + "item", + "biblio", + "branch" + ] + } } } \ No newline at end of file diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc index f1cdfa5ab2..7f06967756 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc +++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/usermenu.inc @@ -58,14 +58,24 @@ your search history [% END %] - [% IF ( opacreadinghistory ) %] - [% IF ( readingrecview ) %] -
  • - [% ELSE %] -
  • + [% IF opacreadinghistory || Koha.Preference('OPACHoldsHistory') == 1 %] + [% IF opacreadinghistory %] + [% IF ( readingrecview ) %] +
  • + [% ELSE %] +
  • + [% END %] + your reading history
  • + [% END %] + [% IF Koha.Preference('OPACHoldsHistory') == 1 %] + [% IF ( holdsrecview ) %] +
  • + [% ELSE %] +
  • + [% END %] + your holds history
  • [% END %] - your reading history - [% IF ( OPACPrivacy ) %] + [% IF ( OPACPrivacy || Koha.Preference('OPACHoldsPrivacy') == 1 ) %] [% IF ( privacyview ) %]
  • [% ELSE %] diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-holdsrecord.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-holdsrecord.tt new file mode 100644 index 0000000000..7ea4348e89 --- /dev/null +++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-holdsrecord.tt @@ -0,0 +1,247 @@ +[% USE raw %] +[% USE Koha %] +[% USE KohaDates %] +[% INCLUDE 'doc-head-open.inc' %] +[% IF ( LibraryNameTitle ) %][% LibraryNameTitle | html %][% ELSE %]Koha online[% END %] catalog › Your holds history +[% INCLUDE 'doc-head-close.inc' %] +[% BLOCK cssinclude %] + +[% END %] + +[% INCLUDE 'bodytag.inc' bodyid='opac-holdsrecord' %] +[% INCLUDE 'masthead.inc' %] +[% #SET AdlibrisEnabled = Koha.Preference('AdlibrisCoversEnabled') %] +[% #SET AdlibrisURL = Koha.Preference('AdlibrisCoversURL') %] + +[% #IF Koha.Preference('AmazonAssocTag') %] + [% #AmazonAssocTag = '?tag=' _ Koha.Preference('AmazonAssocTag') %] +[% #ELSE %] + [% #AmazonAssocTag = '' %] +[% #END %] + +
    + + +
    +
    +
    + +
    +
    +
    +

    Holds history

    + + [% IF READING_RECORD.size == 0 %] + You have never placed a hold from this library. + [% ELSE %] +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + +
    TitleAuthorBarcodeLibraryHold dateExpiration dateWaiting dateCancellation dateStatus
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + [% END # / IF READING_RECORD.size %] +
    +
    +
    +
    +
    + +[% INCLUDE 'opac-bottom.inc' %] +[% BLOCK jsinclude %] +[% INCLUDE 'datatables.inc' %] +[% INCLUDE 'js-date-format.inc' %] + +[% END %] diff --git a/opac/opac-holdsrecord.pl b/opac/opac-holdsrecord.pl new file mode 100755 index 0000000000..f52e5720e7 --- /dev/null +++ b/opac/opac-holdsrecord.pl @@ -0,0 +1,161 @@ +#!/usr/bin/perl + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + + +use Modern::Perl; + +use CGI qw ( -utf8 ); + +use C4::Auth; +use C4::Koha; +use C4::Biblio; +use C4::Circulation; +use C4::Members; +use Koha::DateUtils; +use MARC::Record; + +use C4::Output; +use C4::Charset qw(StripNonXmlChars); +use Koha::Patrons; + +use Koha::ItemTypes; +use Koha::Ratings; + +my $query = new CGI; + +# if opacreadinghistory is disabled, leave immediately +if ( ! C4::Context->preference('OPACHoldsHistory') ) { + print $query->redirect("/cgi-bin/koha/errors/404.pl"); + exit; +} + +my ( $template, $borrowernumber, $cookie ) = get_template_and_user( + { + template_name => "opac-holdsrecord.tt", + query => $query, + type => "opac", + authnotrequired => 0, + debug => 1, + } +); + +my $borr = Koha::Patrons->find( $borrowernumber )->unblessed; + +$template->param(%{$borr}); + +# my $itemtypes = { map { $_->{itemtype} => $_ } @{ Koha::ItemTypes->search_with_localization->unblessed } }; + +# # get the record +# my $order = $query->param('order') || ''; +# if ( $order eq 'title' ) { +# $template->param( orderbytitle => 1 ); +# } +# elsif ( $order eq 'author' ) { +# $template->param( orderbyauthor => 1 ); +# } +# else { +# $order = "date_due desc"; +# $template->param( orderbydate => 1 ); +# } + + +# my $limit = $query->param('limit'); +# $limit //= ''; +# $limit = ( $limit eq 'full' ) ? 0 : 50; + +# # my $issues = GetAllIssues( $borrowernumber, $order, $limit ); + +# # my $itype_attribute = +# # ( C4::Context->preference('item-level_itypes') ) ? 'itype' : 'itemtype'; + +# # my $opac_summary_html = C4::Context->preference('OPACMySummaryHTML'); +# foreach my $issue ( @{$issues} ) { +# $issue->{normalized_isbn} = GetNormalizedISBN( $issue->{isbn} ); +# if ( $issue->{$itype_attribute} ) { +# $issue->{translated_description} = +# $itemtypes->{ $issue->{$itype_attribute} }->{translated_description}; +# $issue->{imageurl} = +# getitemtypeimagelocation( 'opac', +# $itemtypes->{ $issue->{$itype_attribute} }->{imageurl} ); +# } +# my $marcxml = C4::Biblio::GetXmlBiblio( $issue->{biblionumber} ); +# if ( $marcxml ) { +# $marcxml = StripNonXmlChars( $marcxml ); +# my $marc_rec = +# MARC::Record::new_from_xml( $marcxml, 'utf8', +# C4::Context->preference('marcflavour') ); +# $issue->{normalized_upc} = GetNormalizedUPC( $marc_rec, C4::Context->preference('marcflavour') ); +# } +# # My Summary HTML +# if ($opac_summary_html) { +# my $my_summary_html = $opac_summary_html; +# $issue->{author} +# ? $my_summary_html =~ s/{AUTHOR}/$issue->{author}/g +# : $my_summary_html =~ s/{AUTHOR}//g; +# my $title = $issue->{title}; +# $title =~ s/\/+$//; # remove trailing slash +# $title =~ s/\s+$//; # remove trailing space +# $title +# ? $my_summary_html =~ s/{TITLE}/$title/g +# : $my_summary_html =~ s/{TITLE}//g; +# $issue->{normalized_isbn} +# ? $my_summary_html =~ s/{ISBN}/$issue->{normalized_isbn}/g +# : $my_summary_html =~ s/{ISBN}//g; +# $issue->{biblionumber} +# ? $my_summary_html =~ s/{BIBLIONUMBER}/$issue->{biblionumber}/g +# : $my_summary_html =~ s/{BIBLIONUMBER}//g; +# $issue->{MySummaryHTML} = $my_summary_html; +# } +# # Star ratings +# if ( C4::Context->preference('OpacStarRatings') eq 'all' ) { +# my $ratings = Koha::Ratings->search({ biblionumber => $issue->{biblionumber} }); +# $issue->{ratings} = $ratings; +# $issue->{my_rating} = $borrowernumber ? $ratings->search({ borrowernumber => $borrowernumber })->next : undef; +# } +# } + +# if (C4::Context->preference('BakerTaylorEnabled')) { +# $template->param( +# JacketImages=>1, +# BakerTaylorEnabled => 1, +# BakerTaylorImageURL => &image_url(), +# BakerTaylorLinkURL => &link_url(), +# BakerTaylorBookstoreURL => C4::Context->preference('BakerTaylorBookstoreURL'), +# ); +# } + +# BEGIN { +# if (C4::Context->preference('BakerTaylorEnabled')) { +# require C4::External::BakerTaylor; +# import C4::External::BakerTaylor qw(&image_url &link_url); +# } +# } + +# for(qw(AmazonCoverImages GoogleJackets)) { # BakerTaylorEnabled handled above +# C4::Context->preference($_) or next; +# $template->param($_=>1); +# $template->param(JacketImages=>1); +# } + +$template->param( +# READING_RECORD => $issues, +# limit => $limit, + holdsrecview => 1, +# OPACMySummaryHTML => $opac_summary_html ? 1 : 0, +); + +output_html_with_http_headers $query, $cookie, $template->output, undef, { force_no_caching => 1 }; -- 2.21.1 (Apple Git-122.3)